feat: geolocate anonymous widget visitors
Build and publish Docker images / Build and publish images (push) Successful in 2m10s

This commit is contained in:
2026-09-15 08:54:13 +08:00
parent 5a0e9ecada
commit 4d684a71eb
28 changed files with 1502 additions and 104 deletions
+6 -1
View File
@@ -3,7 +3,8 @@ server:
mode: "release"
database:
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
dsn: >-
postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable
run_migrations: false
migrations_path: "/app/migrations"
@@ -33,3 +34,7 @@ worker:
storage:
provider: "local"
local_path: "/app/storage/uploads"
geoip:
# Set GOCHAT_GEOIP_DB_PATH to the read-only MMDB mount in production.
db_path: ""
+71 -25
View File
@@ -8,21 +8,51 @@ server:
idle_timeout_seconds: 120
shutdown_timeout_seconds: 30
max_header_bytes: 1048576
trusted_proxies: [] # add explicit reverse-proxy IPs/CIDRs; XFF is ignored otherwise
# Add explicit reverse-proxy IPs/CIDRs; XFF is ignored otherwise.
trusted_proxies: []
cors:
allowed_origins: [] # retained for config compatibility; GoChat allows all origins
allowed_methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash", "access-token", "client", "uid", "token-type", "expiry"]
expose_headers: ["Content-Length", "access-token", "client", "uid", "token-type", "expiry"]
allow_credentials: false # retained for config compatibility; wildcard CORS does not use credentials
max_age: 86400 # preflight cache duration in seconds
# Retained for config compatibility; GoChat allows all origins.
allowed_origins: []
allowed_methods:
- "GET"
- "POST"
- "PUT"
- "DELETE"
- "PATCH"
- "OPTIONS"
allowed_headers:
- "Origin"
- "Content-Type"
- "Accept"
- "Authorization"
- "X-Account-ID"
- "X-Auth-Token"
- "X-Widget-Token"
- "X-Identifier-Hash"
- "access-token"
- "client"
- "uid"
- "token-type"
- "expiry"
expose_headers:
- "Content-Length"
- "access-token"
- "client"
- "uid"
- "token-type"
- "expiry"
# Retained for config compatibility; wildcard CORS does not use credentials.
allow_credentials: false
# Preflight cache duration in seconds.
max_age: 86400
database:
dsn: "postgres://postgres@localhost:5432/gochat_dev?sslmode=disable"
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 3600 # seconds
run_migrations: true # auto-run migrations on startup (dev convenience)
# Auto-run migrations on startup (dev convenience).
run_migrations: true
migrations_path: "migrations"
redis:
@@ -51,10 +81,18 @@ log:
rate_limit:
enabled: true
login: { requests: 10, window_seconds: 60 }
password_reset: { requests: 5, window_seconds: 300 }
public_upload: { requests: 20, window_seconds: 60 }
webhook: { requests: 120, window_seconds: 60 }
login:
requests: 10
window_seconds: 60
password_reset:
requests: 5
window_seconds: 300
public_upload:
requests: 20
window_seconds: 60
webhook:
requests: 120
window_seconds: 60
search:
# Chatwoot parity target. Use "db" only for explicit local fallback.
@@ -64,19 +102,27 @@ search:
index_prefix: "gochat_"
timeout_seconds: 5
geoip:
# Optional read-only MaxMind-compatible City database. Empty disables lookup.
db_path: ""
saml:
enabled: false # SAML 2.0 SSO — enable for enterprise IdP integration
# IdP metadata: provide URL or inline XML (URL preferred for auto-refresh)
idp_metadata_url: "" # e.g. "https://idp.example.com/metadata"
idp_metadata_xml: "" # fallback: paste IdP metadata XML here
sp_entity_id: "https://gochat.example.com/saml" # our SP entity ID
acs_url: "https://gochat.example.com/api/v1/saml/acs" # Assertion Consumer Service URL
# SP key/certificate: PEM format (required for signed AuthnRequest + response validation)
sp_private_key: "" # path or inline PEM — generate with: openssl genrsa -out sp.key 2048
sp_certificate: "" # path or inline PEM — generate with: openssl req -new -x509 -key sp.key -out sp.crt
clock_drift_tolerance: 180 # seconds of allowed clock drift for NotOnOrAfter validation
# IdP metadata: provide URL or inline XML (URL preferred for auto-refresh).
idp_metadata_url: ""
# Fallback: paste IdP metadata XML here.
idp_metadata_xml: ""
# Our SP entity ID.
sp_entity_id: "https://gochat.example.com/saml"
# Assertion Consumer Service URL.
acs_url: "https://gochat.example.com/api/v1/saml/acs"
# SP key/certificate is PEM format and required for signed AuthnRequest
# and response validation.
sp_private_key: ""
sp_certificate: ""
clock_drift_tolerance: 180 # seconds of allowed clock drift
attribute_map:
email: "email" # SAML attribute → GoChat email field
display_name: "displayName" # SAML attribute → GoChat display name field
first_name: "firstName" # SAML attribute → first name component
last_name: "lastName" # SAML attribute → last name component
email: "email"
display_name: "displayName"
first_name: "firstName"
last_name: "lastName"
+2
View File
@@ -15,6 +15,7 @@ require (
github.com/emersion/go-message v0.18.2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.10.0
github.com/oschwald/geoip2-golang v1.13.0
github.com/go-playground/validator/v10 v10.20.0
github.com/go-resty/resty/v2 v2.16.5
github.com/golang-jwt/jwt/v5 v5.2.2
@@ -57,6 +58,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
+4
View File
@@ -248,6 +248,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.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.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+5
View File
@@ -16,6 +16,7 @@ import (
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/canned"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/geoip"
ws "github.com/gochat/gochat/internal/handler/ws"
"github.com/gochat/gochat/internal/lifecycle"
"github.com/gochat/gochat/internal/model"
@@ -42,6 +43,7 @@ type App struct {
eventPublisher *wspkg.EventPublisher
notificationDeliverySvc *service.NotificationDeliveryService
workerPool *worker.WorkerPool
visitorGeoReader *geoip.Reader
ready *atomic.Bool
notificationRunning atomic.Bool
handlerGroupOnce sync.Once
@@ -64,6 +66,9 @@ func New(cfg *config.Config) (*App, error) {
// Initialize gin engine
gin.SetMode(cfg.Server.Mode)
engine := gin.New()
if err := engine.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil {
return nil, fmt.Errorf("trusted proxy configuration failed: %w", err)
}
engine.Use(gin.Recovery())
// Initialize pub/sub — Redis-backed when Redis is configured, in-memory fallback
+13
View File
@@ -30,6 +30,7 @@ import (
whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/database"
"github.com/gochat/gochat/internal/geoip"
basehandler "github.com/gochat/gochat/internal/handler"
v1 "github.com/gochat/gochat/internal/handler/api/v1"
webhook "github.com/gochat/gochat/internal/handler/webhook"
@@ -98,6 +99,16 @@ func Bootstrap(env string) (*App, error) {
applogger.L().Infof("Configuration loaded (env=%s, mode=%s)", env, cfg.Server.Mode)
var visitorGeoReader *geoip.Reader
if path := strings.TrimSpace(cfg.GeoIP.DBPath); path != "" {
visitorGeoReader, err = geoip.Open(path)
if err != nil {
applogger.L().Warnf("GeoIP database unavailable at %s; anonymous visitors will use fallback names: %v", path, err)
} else {
applogger.L().Infof("GeoIP database opened: %s", path)
}
}
// Step 4: Connect to PostgreSQL (ref: Chatwoot config/database.yml)
db, err := NewDatabase(&cfg.Database, cfg.Log.Level)
if err != nil {
@@ -807,6 +818,7 @@ func Bootstrap(env string) (*App, error) {
widgetFileUploadRepo := repository.NewWidgetFileUploadRepo(db)
widgetOfflineMessageRepo := repository.NewWidgetOfflineMessageRepo(db)
widgetService := service.NewWidgetService(inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, widgetTypingAdapter, widgetThemeConfigRepo, preChatFormRepo, widgetFileUploadRepo, widgetOfflineMessageRepo, inboxMemberRepo, tagRepo, campaignRepo)
widgetService.SetVisitorGeoResolver(visitorGeoReader)
widgetService.SetWorkerPool(workerPool)
widgetService.SetSearchIndexer(searchIndexer)
widgetService.SetDispatcher(channelDispatcher)
@@ -1048,6 +1060,7 @@ func Bootstrap(env string) (*App, error) {
eventPublisher: eventPublisher,
notificationDeliverySvc: notificationDeliverySvc,
workerPool: workerPool,
visitorGeoReader: visitorGeoReader,
ready: ready,
}
notificationDeliverySvc.SetHandlerGroup(application.handlers())
+10
View File
@@ -73,6 +73,16 @@ func (a *App) shutdown(ctx context.Context) error {
}
}
// Close the shared local GeoIP reader after all HTTP handlers have drained.
if a.visitorGeoReader != nil {
if err := a.visitorGeoReader.Close(); err != nil {
applogger.L().Errorf("GeoIP database close error: %v", err)
shutdownErrs = append(shutdownErrs, err)
} else {
applogger.L().Info("GeoIP database closed")
}
}
// Step 2: Close PubSub — stop event publishing and consuming
if a.pubsub != nil {
if closer, ok := a.pubsub.(interface{ Close() error }); ok {
+11
View File
@@ -39,6 +39,7 @@ type Config struct {
CSRF CSRFConfig `mapstructure:"csrf"`
Session SessionConfig `mapstructure:"session"`
Storage StorageConfig `mapstructure:"storage"`
GeoIP GeoIPConfig `mapstructure:"geoip"`
Copilot CopilotConfig `mapstructure:"copilot"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Encryption EncryptionConfig `mapstructure:"encryption"`
@@ -280,6 +281,11 @@ type StorageConfig struct {
MaxFileSize int64 `mapstructure:"max_file_size"` // Maximum file size in bytes (default 20MB)
}
// GeoIPConfig configures the optional local MaxMind-compatible City database.
type GeoIPConfig struct {
DBPath string `mapstructure:"db_path"`
}
// Load reads config from file and environment.
func Load() (*Config, error) {
viper.SetConfigName("config")
@@ -310,6 +316,7 @@ func Load() (*Config, error) {
viper.SetDefault("rate_limit.webhook.window_seconds", 60)
viper.SetDefault("encryption.enabled", false)
viper.SetDefault("encryption.current_key_version", 1)
viper.SetDefault("geoip.db_path", "")
// Set defaults for push notifications
viper.SetDefault("push.enabled", false)
@@ -584,6 +591,7 @@ func LoadWithEnv(env string) (*Config, error) {
"GOCHAT_STORAGE_PROVIDER": "storage.provider",
"GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path",
"GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size",
"GOCHAT_GEOIP_DB_PATH": "geoip.db_path",
// G10: OAuth config for new channel integrations (Twitter, Microsoft, Google)
"GOCHAT_OAUTH_TWITTER_CLIENT_ID": "oauth.twitter.client_id",
"GOCHAT_OAUTH_TWITTER_CLIENT_SECRET": "oauth.twitter.client_secret",
@@ -837,6 +845,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("storage.local_path", "./uploads")
v.SetDefault("storage.max_file_size", 20*1024*1024) // 20MB
// GeoIP defaults: empty path disables local lookup with a safe fallback.
v.SetDefault("geoip.db_path", "")
v.SetDefault("copilot.provider_config", "")
v.SetDefault("copilot.chat_api_key", "")
v.SetDefault("copilot.embedding_api_key", "")
+15
View File
@@ -0,0 +1,15 @@
package config
import (
"testing"
"github.com/spf13/viper"
)
func TestSetDefaultsGeoIPPathIsOptional(t *testing.T) {
v := viper.New()
setDefaults(v)
if got := v.GetString("geoip.db_path"); got != "" {
t.Fatalf("geoip.db_path default = %q, want empty", got)
}
}
+89
View File
@@ -0,0 +1,89 @@
// Package geoip provides the local IP-to-region lookup used for anonymous
// Web Widget contacts. It deliberately has no network fallback: missing or
// stale data must not make a public widget request fail.
package geoip
import (
"net"
"strings"
maxmind "github.com/oschwald/geoip2-golang"
)
// Location is the small region payload needed by the Web Widget name policy.
type Location struct {
Province string
City string
}
// Resolver is the service-facing GeoIP contract. Keeping the lookup boundary
// small makes widget behavior testable without shipping a production MMDB file
// in the repository.
type Resolver interface {
Lookup(net.IP) (Location, bool)
}
// Reader wraps a MaxMind City database reader.
type Reader struct {
db *maxmind.Reader
}
// Open opens a local MaxMind-compatible City database.
func Open(path string) (*Reader, error) {
if strings.TrimSpace(path) == "" {
return nil, nil
}
db, err := maxmind.Open(path)
if err != nil {
return nil, err
}
return &Reader{db: db}, nil
}
// Lookup resolves a public IP to a province/city pair.
func (r *Reader) Lookup(ip net.IP) (Location, bool) {
if r == nil || r.db == nil || isNonPublicIP(ip) {
return Location{}, false
}
record, err := r.db.City(ip)
if err != nil {
return Location{}, false
}
province := ""
if len(record.Subdivisions) > 0 {
province = localizedName(record.Subdivisions[0].Names)
}
return Location{
Province: province,
City: localizedName(record.City.Names),
}, true
}
// Close releases the underlying MMDB file descriptor.
func (r *Reader) Close() error {
if r == nil || r.db == nil {
return nil
}
return r.db.Close()
}
func isNonPublicIP(ip net.IP) bool {
return ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast()
}
func localizedName(names map[string]string) string {
for _, locale := range []string{"zh-CN", "zh", "en"} {
if name := strings.TrimSpace(names[locale]); name != "" {
return name
}
}
for _, name := range names {
if name = strings.TrimSpace(name); name != "" {
return name
}
}
return ""
}
+44
View File
@@ -0,0 +1,44 @@
package geoip
import (
"net"
"testing"
)
func TestOpenEmptyPathDisablesLookup(t *testing.T) {
reader, err := Open(" ")
if err != nil {
t.Fatal(err)
}
if reader != nil {
t.Fatalf("expected no reader for an empty path")
}
}
func TestOpenMissingDatabaseReturnsError(t *testing.T) {
if _, err := Open("/does/not/exist/GeoLite2-City.mmdb"); err == nil {
t.Fatal("expected an error for a missing database")
}
}
func TestLookupRejectsNonPublicAddresses(t *testing.T) {
reader := &Reader{}
for _, address := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "::1", "fc00::1"} {
location, ok := reader.Lookup(net.ParseIP(address))
if ok || location != (Location{}) {
t.Fatalf("expected %s to be rejected, got %#v, %v", address, location, ok)
}
}
}
func TestLocalizedNamePrefersChineseThenEnglish(t *testing.T) {
if got := localizedName(map[string]string{"en": "Hebei", "zh-CN": "河北"}); got != "河北" {
t.Fatalf("got %q, want 河北", got)
}
if got := localizedName(map[string]string{"en": "Beijing"}); got != "Beijing" {
t.Fatalf("got %q, want Beijing", got)
}
if got := localizedName(map[string]string{}); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
@@ -0,0 +1,101 @@
package widget
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gochat/gochat/internal/geoip"
"github.com/gochat/gochat/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
)
type handlerGeoResolver struct {
location geoip.Location
seenIP net.IP
}
func (r *handlerGeoResolver) Lookup(ip net.IP) (geoip.Location, bool) {
r.seenIP = append(net.IP(nil), ip...)
return r.location, true
}
func TestWidgetHandler_ConfigUsesServerClientIPForAnonymousName(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
resolver := &handlerGeoResolver{location: geoip.Location{Province: "河北省", City: "保定市"}}
handler.widgetService.SetVisitorGeoResolver(resolver)
recorder := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodPost, "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
require.NoError(t, err)
req.RemoteAddr = "203.0.113.20:4567"
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "203.0.113.20", resolver.seenIP.String())
var payload map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
contact, ok := payload["contact"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "河北保定客户", contact["name"])
}
func TestWidgetHandler_InitIgnoresClientSuppliedIP(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
require.NoError(t, router.SetTrustedProxies(nil))
resolver := &handlerGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}}
handler.widgetService.SetVisitorGeoResolver(resolver)
recorder := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodPost, "/widget/init", strings.NewReader(`{"website_token":"handler_ws_token_123","client_ip":"198.51.100.99"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "198.51.100.99")
req.RemoteAddr = "203.0.113.21:4567"
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "203.0.113.21", resolver.seenIP.String())
var contact model.Contact
require.NoError(t, db.First(&contact).Error)
assert.Equal(t, "北京客户", contact.Name)
}
func TestWidgetHandler_ConfigHonorsConfiguredTrustedProxyIP(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
require.NoError(t, router.SetTrustedProxies([]string{"203.0.113.0/24"}))
resolver := &handlerGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}}
handler.widgetService.SetVisitorGeoResolver(resolver)
recorder := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodPost, "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
require.NoError(t, err)
req.Header.Set("X-Forwarded-For", "198.51.100.44")
req.RemoteAddr = "203.0.113.21:4567"
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "198.51.100.44", resolver.seenIP.String())
}
func TestWidgetContactFullPayloadDoesNotExposeVisitorMetadata(t *testing.T) {
contact := &model.Contact{
Name: "河北保定客户",
AdditionalAttributes: datatypes.JSON(`{"visitor_name_source":"ip_geolocation","visitor_province":"河北","visitor_city":"保定","customer_note":"keep"}`),
}
payload := widgetContactFullPayload(contact)
attributes, ok := payload["additional_attributes"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "keep", attributes["customer_note"])
assert.NotContains(t, attributes, "visitor_name_source")
assert.NotContains(t, attributes, "visitor_province")
assert.NotContains(t, attributes, "visitor_city")
}
@@ -120,6 +120,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
if identifierHash == "" {
identifierHash = c.Query("identifier_hash")
}
req.ClientIP = c.ClientIP()
if req.Identifier != "" && identifierHash != "" {
// Resolve the inbox to get its hmac_token for verification
inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), req.WebsiteToken)
@@ -159,6 +160,7 @@ func (h *WidgetHandler) Config(c *gin.Context) {
if req.WidgetToken == "" {
req.WidgetToken = widgetTokenFromRequest(c)
}
req.ClientIP = c.ClientIP()
resp, err := h.widgetService.Init(c.Request.Context(), req)
if err != nil {
@@ -631,6 +633,7 @@ func (h *WidgetHandler) SetUser(c *gin.Context) {
PhoneNumber: req.PhoneNumber,
CustomAttributes: req.CustomAttributes,
AdditionalAttributes: req.AdditionalAttributes,
ClientIP: c.ClientIP(),
})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
@@ -987,6 +990,7 @@ func (h *WidgetHandler) PublicCreateContact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
req.ClientIP = c.ClientIP()
resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
@@ -1010,6 +1014,7 @@ func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
req.ClientIP = c.ClientIP()
resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
@@ -1205,6 +1210,7 @@ func (h *WidgetHandler) SubmitOfflineMessage(c *gin.Context) {
referer := c.Request.Referer()
browserInfo := c.GetHeader("User-Agent")
submission.ClientIP = c.ClientIP()
msg, err := h.widgetService.SubmitOfflineMessage(
c.Request.Context(),
@@ -1428,7 +1434,7 @@ func widgetContactFullPayload(contact *model.Contact) gin.H {
"avatar_url": contact.AvatarURL,
"identifier": contact.Identifier,
"custom_attributes": contact.CustomAttributes,
"additional_attributes": contact.AdditionalAttributes,
"additional_attributes": service.PublicWidgetAdditionalAttributes(contact.AdditionalAttributes),
}
}
@@ -1548,7 +1548,7 @@ func TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK(t *testing.T
var activity model.Message
require.NoError(t, db.Where("message_type = ?", string(model.MessageTypeActivity)).First(&activity).Error)
assert.Equal(t, "Conversation was resolved by Anonymous Visitor", activity.Content)
assert.Equal(t, "Conversation was resolved by 匿名客户", activity.Content)
}
func TestWidgetHandler_ChatwootToggleStatusHonorsEndConversationFlag(t *testing.T) {
@@ -15,20 +15,20 @@ import "time"
// that agents can respond to when they return.
type WidgetOfflineMessage struct {
Base
InboxID uint `gorm:"index;not null" json:"inbox_id"` // The web_widget inbox
AccountID uint `gorm:"index;not null" json:"account_id"` // Account scope for the message
ContactName string `gorm:"size:255" json:"contact_name,omitempty"` // Visitor name (from pre-chat form or anonymous)
ContactEmail string `gorm:"size:512" json:"contact_email,omitempty"` // Visitor email (from pre-chat form)
ContactPhone string `gorm:"size:30" json:"contact_phone,omitempty"` // Visitor phone (from pre-chat form)
ContactCompany string `gorm:"size:255" json:"contact_company,omitempty"` // Visitor company (from pre-chat form)
ContactCity string `gorm:"size:255" json:"contact_city,omitempty"` // Visitor city (from pre-chat form)
ContactCountry string `gorm:"size:255" json:"contact_country,omitempty"` // Visitor country (from pre-chat form)
Content string `gorm:"type:text;not null" json:"content"` // The message content
Referer string `gorm:"size:1024" json:"referer,omitempty"` // Page URL where widget was embedded
BrowserInfo string `gorm:"size:512" json:"browser_info,omitempty"` // Browser metadata
ConversationID *uint `gorm:"index" json:"conversation_id,omitempty"` // Set when converted to a conversation (null until then)
Status OfflineStatus `gorm:"size:30;default:'pending'" json:"status"` // pending → converted → dismissed
ConvertedAt *time.Time `json:"converted_at,omitempty"` // When the message was converted to a conversation
InboxID uint `gorm:"index;not null" json:"inbox_id"` // The web_widget inbox
AccountID uint `gorm:"index;not null" json:"account_id"` // Account scope for the message
ContactName string `gorm:"size:255" json:"contact_name,omitempty"` // Visitor name (from pre-chat form or anonymous)
ContactEmail string `gorm:"size:512" json:"contact_email,omitempty"` // Visitor email (from pre-chat form)
ContactPhone string `gorm:"size:30" json:"contact_phone,omitempty"` // Visitor phone (from pre-chat form)
ContactCompany string `gorm:"size:255" json:"contact_company,omitempty"` // Visitor company (from pre-chat form)
ContactCity string `gorm:"size:255" json:"contact_city,omitempty"` // Visitor city (from pre-chat form)
ContactCountry string `gorm:"size:255" json:"contact_country,omitempty"` // Visitor country (from pre-chat form)
Content string `gorm:"type:text;not null" json:"content"` // The message content
Referer string `gorm:"size:1024" json:"referer,omitempty"` // Page URL where widget was embedded
BrowserInfo string `gorm:"size:512" json:"browser_info,omitempty"` // Browser metadata
ConversationID *uint `gorm:"index" json:"conversation_id,omitempty"` // Set when converted to a conversation (null until then)
Status OfflineStatus `gorm:"size:30;default:'pending'" json:"status"` // pending → converted → dismissed
ConvertedAt *time.Time `json:"converted_at,omitempty"` // When the message was converted to a conversation
}
func (WidgetOfflineMessage) TableName() string { return "widget_offline_messages" }
@@ -38,18 +38,19 @@ type OfflineStatus string
const (
OfflineStatusPending OfflineStatus = "pending" // Awaiting agent to come online
OfflineStatusConverted OfflineStatus = "converted" // Converted into a conversation
OfflineStatusDismissed OfflineStatus = "dismissed" // Dismissed by admin without conversion
OfflineStatusConverted OfflineStatus = "converted" // Converted into a conversation
OfflineStatusDismissed OfflineStatus = "dismissed" // Dismissed by admin without conversion
)
// WidgetOfflineMessageSubmission is the request body from the widget SDK
// when a visitor submits a message while agents are offline.
type WidgetOfflineMessageSubmission struct {
Name string `json:"name,omitempty"` // Visitor name
Email string `json:"email,omitempty"` // Visitor email
Phone string `json:"phone,omitempty"` // Visitor phone
Company string `json:"company,omitempty"` // Visitor company
City string `json:"city,omitempty"` // Visitor city
Country string `json:"country,omitempty"` // Visitor country
Message string `json:"message" binding:"required"` // The offline message content
}
Name string `json:"name,omitempty"` // Visitor name
Email string `json:"email,omitempty"` // Visitor email
Phone string `json:"phone,omitempty"` // Visitor phone
Company string `json:"company,omitempty"` // Visitor company
City string `json:"city,omitempty"` // Visitor city
Country string `json:"country,omitempty"` // Visitor country
Message string `json:"message" binding:"required"` // The offline message content
ClientIP string `json:"-"` // Set only by the trusted HTTP handler
}
@@ -151,7 +151,7 @@ func TestWidgetService_SubmitOfflineMessage_MinimalFields(t *testing.T) {
require.NoError(t, err)
assert.NotNil(t, msg)
assert.Equal(t, "Just a message, no contact info", msg.Content)
assert.Empty(t, msg.ContactName)
assert.Equal(t, "匿名客户", msg.ContactName)
assert.Empty(t, msg.ContactEmail)
assert.Empty(t, msg.ContactPhone)
assert.Empty(t, msg.Referer)
+85 -10
View File
@@ -14,6 +14,7 @@ import (
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/geoip"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
@@ -69,6 +70,7 @@ type WidgetService struct {
dispatcher *channel.Dispatcher
realtime *wsevent.BridgeListener
searchIndexer SearchIndexer
visitorGeoResolver geoip.Resolver
}
// NewWidgetService creates a new Widget service.
@@ -125,6 +127,12 @@ func (s *WidgetService) SetSearchIndexer(indexer SearchIndexer) {
s.searchIndexer = indexer
}
// SetVisitorGeoResolver configures the local IP-to-region lookup used for
// anonymous Web Widget contacts. A nil resolver keeps the safe fallback name.
func (s *WidgetService) SetVisitorGeoResolver(resolver geoip.Resolver) {
s.visitorGeoResolver = resolver
}
func (s *WidgetService) indexConversation(ctx context.Context, conversation *model.Conversation) {
if s.searchIndexer != nil && conversation != nil {
logSearchIndexError("conversation", conversation.ID, s.searchIndexer.IndexConversation(ctx, conversation))
@@ -150,6 +158,7 @@ type WidgetInitRequest struct {
ContactPhone string `json:"contact_phone,omitempty"`
Identifier string `json:"identifier,omitempty"`
HMACVerified bool `json:"hmac_verified,omitempty"` // true if client validated HMAC
ClientIP string `json:"-"`
}
// WidgetInitResponse is returned after successful widget init/auth.
@@ -206,6 +215,7 @@ type WidgetSetUserRequest struct {
PhoneNumber string
CustomAttributes map[string]any
AdditionalAttributes map[string]any
ClientIP string
}
type WidgetSetUserResponse struct {
@@ -231,6 +241,7 @@ type PublicContactRequest struct {
PhoneNumber string
CustomAttributes map[string]any
AdditionalAttributes map[string]any
ClientIP string
}
type PublicContactResponse struct {
@@ -333,6 +344,12 @@ func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*Widge
return nil, fmt.Errorf("failed to identify contact: %w", err)
}
}
if strings.TrimSpace(req.ContactName) == "" {
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
if err != nil {
return nil, fmt.Errorf("failed to enrich anonymous contact: %w", err)
}
}
// Step 3: Find or create ContactInbox
if contactInbox == nil {
@@ -828,10 +845,15 @@ func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, w
}
func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.Contact, req WidgetContactUpdate) (*model.Contact, error) {
if req.Name != "" {
if !isGenericShangwutongName(req.Name) || isGenericShangwutongName(contact.Name) {
contact.Name = req.Name
if strings.TrimSpace(req.Name) != "" && (!isGenericShangwutongName(req.Name) || isGenericShangwutongName(contact.Name)) {
contact.Name = req.Name
attributes := jsonMap(contact.AdditionalAttributes)
for key := range attributes {
if isServerManagedVisitorAttribute(key) {
delete(attributes, key)
}
}
contact.AdditionalAttributes = mustJSON(attributes)
}
if req.Email != "" {
contact.Email = req.Email
@@ -855,6 +877,9 @@ func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.
if len(req.AdditionalAttributes) > 0 {
merged := jsonMap(contact.AdditionalAttributes)
for k, v := range req.AdditionalAttributes {
if isServerManagedVisitorAttribute(k) {
continue
}
merged[k] = v
}
contact.AdditionalAttributes = mustJSON(merged)
@@ -905,6 +930,12 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier
if err != nil {
return nil, err
}
if strings.TrimSpace(req.Name) == "" {
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
if err != nil {
return nil, err
}
}
existingInbox.Contact = *contact
return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil
}
@@ -962,6 +993,12 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier
if err != nil {
return nil, err
}
if strings.TrimSpace(req.Name) == "" {
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
if err != nil {
return nil, err
}
}
if req.IdentifierHash != "" && !contactInbox.HMACVerified {
contactInbox.HMACVerified = true
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
@@ -1319,6 +1356,12 @@ func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) (
if err != nil {
return nil, err
}
if strings.TrimSpace(req.Name) == "" {
contact, err = s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
if err != nil {
return nil, err
}
}
if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !currentContactInbox.HMACVerified {
currentContactInbox.HMACVerified = true
if err := s.contactInboxRepo.Update(ctx, currentContactInbox); err != nil {
@@ -1717,6 +1760,9 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
if req.ContactEmail != "" {
contact, err := s.contactRepo.FindByEmail(ctx, accountID, req.ContactEmail)
if err == nil {
if strings.TrimSpace(req.ContactName) == "" {
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
}
return contact, nil
}
}
@@ -1728,14 +1774,20 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
}
contacts, _, err := s.contactRepo.Search(ctx, accountID, searchQuery, 0, 5, "id ASC", search.SearchModeILike)
if err == nil && len(contacts) > 0 {
return &contacts[0], nil
contact := &contacts[0]
if strings.TrimSpace(req.ContactName) == "" {
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
}
return contact, nil
}
}
// Create new contact
name := req.ContactName
name := strings.TrimSpace(req.ContactName)
attributes := map[string]any{}
if name == "" {
name = "Anonymous Visitor"
identity := s.anonymousVisitorIdentity(req.ClientIP)
name = identity.name
attributes = identity.attributes
}
contact := model.Contact{
@@ -1746,6 +1798,9 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
Identifier: req.Identifier,
ContactType: "visitor",
}
if len(attributes) > 0 {
contact.AdditionalAttributes = mustJSON(attributes)
}
if err := s.contactRepo.Create(ctx, &contact); err != nil {
return nil, err
@@ -1946,10 +2001,14 @@ func ParseWebWidgetConfig(channelConfig string) (*WebWidgetConfig, error) {
// SubmitOfflineMessage stores a message from a visitor when agents are offline.
// Returns the created offline message record.
func (s *WidgetService) SubmitOfflineMessage(ctx context.Context, inboxID uint, accountID uint, submission *model.WidgetOfflineMessageSubmission, referer, browserInfo string) (*model.WidgetOfflineMessage, error) {
contactName := strings.TrimSpace(submission.Name)
if contactName == "" {
contactName = s.anonymousVisitorIdentity(submission.ClientIP).name
}
msg := &model.WidgetOfflineMessage{
InboxID: inboxID,
AccountID: accountID,
ContactName: submission.Name,
ContactName: contactName,
ContactEmail: submission.Email,
ContactPhone: submission.Phone,
ContactCompany: submission.Company,
@@ -2128,17 +2187,33 @@ func splitWidgetLabels(raw string) []string {
func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, req PublicContactRequest) (*model.Contact, error) {
if req.Identifier != "" {
if contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, req.Identifier); err == nil {
if strings.TrimSpace(req.Name) == "" {
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
}
return contact, nil
}
}
if req.Email != "" {
if contact, err := s.contactRepo.FindByEmail(ctx, accountID, strings.ToLower(req.Email)); err == nil {
if strings.TrimSpace(req.Name) == "" {
return s.enrichAnonymousWidgetContact(ctx, contact, req.ClientIP)
}
return contact, nil
}
}
name := strings.TrimSpace(req.Name)
attributes := filterWidgetAdditionalAttributes(req.AdditionalAttributes)
if name == "" {
identity := s.anonymousVisitorIdentity(req.ClientIP)
name = identity.name
for key, value := range identity.attributes {
attributes[key] = value
}
}
contact := &model.Contact{
AccountID: accountID,
Name: req.Name,
Name: name,
Email: strings.ToLower(req.Email),
PhoneNumber: req.PhoneNumber,
AvatarURL: req.AvatarURL,
@@ -2146,7 +2221,7 @@ func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, r
SourceID: req.SourceID,
ContactType: "visitor",
CustomAttributes: mustJSON(req.CustomAttributes),
AdditionalAttributes: mustJSON(req.AdditionalAttributes),
AdditionalAttributes: mustJSON(attributes),
}
if err := s.contactRepo.Create(ctx, contact); err != nil {
return nil, err
+158
View File
@@ -0,0 +1,158 @@
package service
import (
"context"
"net"
"reflect"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"gorm.io/datatypes"
)
const (
legacyAnonymousVisitorName = "Anonymous Visitor"
anonymousVisitorName = "匿名客户"
visitorNameSourceKey = "visitor_name_source"
visitorProvinceKey = "visitor_province"
visitorCityKey = "visitor_city"
visitorGeoAtKey = "visitor_geo_at"
visitorNameSourceGeoIP = "ip_geolocation"
visitorNameSourceFallback = "anonymous_fallback"
)
type visitorIdentity struct {
name string
attributes map[string]any
}
func (s *WidgetService) anonymousVisitorIdentity(clientIP string) visitorIdentity {
identity := visitorIdentity{
name: anonymousVisitorName,
attributes: map[string]any{
visitorNameSourceKey: visitorNameSourceFallback,
},
}
if s.visitorGeoResolver == nil {
return identity
}
ip := net.ParseIP(strings.TrimSpace(clientIP))
if ip == nil {
return identity
}
location, ok := s.visitorGeoResolver.Lookup(ip)
if !ok {
return identity
}
name := formatVisitorName(location.Province, location.City)
if name == "" {
return identity
}
identity.name = name
identity.attributes = map[string]any{
visitorNameSourceKey: visitorNameSourceGeoIP,
visitorGeoAtKey: time.Now().UTC().Format(time.RFC3339),
}
if province := normalizeRegionPart(location.Province); province != "" {
identity.attributes[visitorProvinceKey] = province
}
if city := normalizeRegionPart(location.City); city != "" {
identity.attributes[visitorCityKey] = city
}
return identity
}
func (s *WidgetService) enrichAnonymousWidgetContact(ctx context.Context, contact *model.Contact, clientIP string) (*model.Contact, error) {
if contact == nil || !isAnonymousWidgetContact(contact) {
return contact, nil
}
attributes := jsonMap(contact.AdditionalAttributes)
if source, _ := attributes[visitorNameSourceKey].(string); source == visitorNameSourceGeoIP {
return contact, nil
}
identity := s.anonymousVisitorIdentity(clientIP)
if contact.Name == identity.name && reflect.DeepEqual(attributes[visitorNameSourceKey], identity.attributes[visitorNameSourceKey]) {
return contact, nil
}
contact.Name = identity.name
for key, value := range identity.attributes {
attributes[key] = value
}
contact.AdditionalAttributes = mustJSON(attributes)
if err := s.contactRepo.Update(ctx, contact); err != nil {
return nil, err
}
return contact, nil
}
func isAnonymousWidgetContact(contact *model.Contact) bool {
if contact == nil {
return false
}
name := strings.TrimSpace(contact.Name)
if name == "" || name == legacyAnonymousVisitorName || name == anonymousVisitorName {
return true
}
attributes := jsonMap(contact.AdditionalAttributes)
source, _ := attributes[visitorNameSourceKey].(string)
if source == visitorNameSourceGeoIP {
return true
}
return source == visitorNameSourceFallback && (name == "" || name == legacyAnonymousVisitorName || name == anonymousVisitorName)
}
func formatVisitorName(province, city string) string {
province = normalizeRegionPart(province)
city = normalizeRegionPart(city)
base := city
if base == "" {
base = province
} else if province != "" && city != province && !strings.HasPrefix(city, province) {
base = province + city
}
if base == "" {
return ""
}
return base + "客户"
}
func normalizeRegionPart(value string) string {
value = strings.TrimSpace(value)
for _, suffix := range []string{"特别行政区", "自治区", "自治州", "省", "市", "地区", "盟"} {
if strings.HasSuffix(value, suffix) && len([]rune(value)) > len([]rune(suffix)) {
return strings.TrimSpace(strings.TrimSuffix(value, suffix))
}
}
return value
}
func isServerManagedVisitorAttribute(key string) bool {
switch key {
case visitorNameSourceKey, visitorProvinceKey, visitorCityKey, visitorGeoAtKey, "created_at_ip":
return true
default:
return false
}
}
func filterWidgetAdditionalAttributes(attributes map[string]any) map[string]any {
filtered := make(map[string]any, len(attributes))
for key, value := range attributes {
if !isServerManagedVisitorAttribute(key) {
filtered[key] = value
}
}
return filtered
}
// PublicWidgetAdditionalAttributes removes server-owned visitor metadata from
// the public widget payload while retaining customer-provided attributes.
func PublicWidgetAdditionalAttributes(raw datatypes.JSON) map[string]any {
return filterWidgetAdditionalAttributes(jsonMap(raw))
}
@@ -0,0 +1,245 @@
package service
import (
"context"
"encoding/json"
"net"
"testing"
"github.com/gochat/gochat/internal/geoip"
"github.com/gochat/gochat/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeVisitorGeoResolver struct {
location geoip.Location
ok bool
seenIP net.IP
}
func (f *fakeVisitorGeoResolver) Lookup(ip net.IP) (geoip.Location, bool) {
f.seenIP = append(net.IP(nil), ip...)
return f.location, f.ok
}
func TestFormatVisitorName(t *testing.T) {
tests := []struct {
name string
province string
city string
want string
}{
{name: "province and city", province: "河北省", city: "保定市", want: "河北保定客户"},
{name: "municipality", province: "北京市", city: "北京市", want: "北京客户"},
{name: "province only", province: "河北省", want: "河北客户"},
{name: "empty", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, formatVisitorName(tt.province, tt.city))
})
}
}
func TestWidgetService_InitUsesGeoIPNameWithoutPersistingIP(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
_, inbox := seedWidgetInbox(t, db)
resolver := &fakeVisitorGeoResolver{
location: geoip.Location{Province: "河北省", City: "保定市"},
ok: true,
}
svc.SetVisitorGeoResolver(resolver)
resp, err := svc.Init(context.Background(), WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ClientIP: "203.0.113.10",
})
require.NoError(t, err)
require.NotNil(t, resp.Contact)
assert.Equal(t, inbox.ID, resp.InboxID)
assert.Equal(t, "203.0.113.10", resolver.seenIP.String())
assert.Equal(t, "河北保定客户", resp.Contact.Name)
var attributes map[string]any
require.NoError(t, json.Unmarshal(resp.Contact.AdditionalAttributes, &attributes))
assert.Equal(t, visitorNameSourceGeoIP, attributes[visitorNameSourceKey])
assert.Equal(t, "河北", attributes[visitorProvinceKey])
assert.Equal(t, "保定", attributes[visitorCityKey])
assert.NotContains(t, attributes, "client_ip")
assert.NotContains(t, attributes, "created_at_ip")
}
func TestWidgetService_InitFallsBackWhenGeoIPHasNoRecord(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
seedWidgetInbox(t, db)
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{ok: false})
resp, err := svc.Init(context.Background(), WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ClientIP: "198.51.100.20",
})
require.NoError(t, err)
assert.Equal(t, anonymousVisitorName, resp.Contact.Name)
}
func TestWidgetService_InitPreservesRealName(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
seedWidgetInbox(t, db)
resolver := &fakeVisitorGeoResolver{location: geoip.Location{Province: "河北", City: "保定"}, ok: true}
svc.SetVisitorGeoResolver(resolver)
resp, err := svc.Init(context.Background(), WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "张三",
ClientIP: "203.0.113.11",
})
require.NoError(t, err)
assert.Equal(t, "张三", resp.Contact.Name)
assert.Nil(t, resolver.seenIP)
}
func TestWidgetService_InitUpgradesLegacyAnonymousName(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, _ := seedWidgetInbox(t, db)
legacy := &model.Contact{
AccountID: account.ID,
Name: legacyAnonymousVisitorName,
Email: "legacy@example.com",
ContactType: "visitor",
}
require.NoError(t, db.Create(legacy).Error)
resolver := &fakeVisitorGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}, ok: true}
svc.SetVisitorGeoResolver(resolver)
resp, err := svc.Init(context.Background(), WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactEmail: "legacy@example.com",
ClientIP: "203.0.113.12",
})
require.NoError(t, err)
assert.Equal(t, legacy.ID, resp.ContactID)
assert.Equal(t, "北京客户", resp.Contact.Name)
}
func TestWidgetService_RealNameWinsOverGeneratedName(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, _ := seedWidgetInbox(t, db)
contact := &model.Contact{
AccountID: account.ID,
Name: anonymousVisitorName,
AdditionalAttributes: mustJSON(map[string]any{
visitorNameSourceKey: visitorNameSourceFallback,
}),
}
require.NoError(t, db.Create(contact).Error)
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
location: geoip.Location{Province: "河北", City: "保定"},
ok: true,
})
updated, err := svc.updateContactFields(context.Background(), contact, WidgetContactUpdate{Name: "张三"})
require.NoError(t, err)
assert.Equal(t, "张三", updated.Name)
assert.Empty(t, jsonMap(updated.AdditionalAttributes)[visitorNameSourceKey])
updated, err = svc.enrichAnonymousWidgetContact(context.Background(), updated, "203.0.113.14")
require.NoError(t, err)
assert.Equal(t, "张三", updated.Name)
}
func TestWidgetService_DoesNotOverwriteManuallyRenamedFallbackContact(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
contact := &model.Contact{
Name: "客服改名",
AdditionalAttributes: mustJSON(map[string]any{
visitorNameSourceKey: visitorNameSourceFallback,
}),
}
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
location: geoip.Location{Province: "河北", City: "保定"},
ok: true,
})
updated, err := svc.enrichAnonymousWidgetContact(context.Background(), contact, "203.0.113.15")
require.NoError(t, err)
assert.Equal(t, "客服改名", updated.Name)
}
func TestWidgetService_ProtectsVisitorAttributesFromClientUpdates(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, _ := seedWidgetInbox(t, db)
contact := &model.Contact{
AccountID: account.ID,
Name: "河北保定客户",
AdditionalAttributes: mustJSON(map[string]any{
visitorNameSourceKey: visitorNameSourceGeoIP,
visitorProvinceKey: "河北",
visitorCityKey: "保定",
}),
}
require.NoError(t, db.Create(contact).Error)
updated, err := svc.updateContactFields(context.Background(), contact, WidgetContactUpdate{
AdditionalAttributes: map[string]any{
visitorNameSourceKey: "spoofed",
visitorProvinceKey: "北京",
"customer_note": "kept",
},
})
require.NoError(t, err)
attributes := map[string]any{}
require.NoError(t, json.Unmarshal(updated.AdditionalAttributes, &attributes))
assert.Equal(t, visitorNameSourceGeoIP, attributes[visitorNameSourceKey])
assert.Equal(t, "河北", attributes[visitorProvinceKey])
assert.Equal(t, "kept", attributes["customer_note"])
}
func TestWidgetService_PublicContactUsesGeoIPNameAndFiltersClientMetadata(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, _ := seedWidgetInbox(t, db)
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
location: geoip.Location{Province: "北京市", City: "北京市"},
ok: true,
})
contact, err := svc.findPublicContact(context.Background(), account.ID, PublicContactRequest{
AdditionalAttributes: map[string]any{
visitorNameSourceKey: "spoofed",
"customer_note": "kept",
},
ClientIP: "203.0.113.16",
})
require.NoError(t, err)
assert.Equal(t, "北京客户", contact.Name)
attributes := jsonMap(contact.AdditionalAttributes)
assert.Equal(t, visitorNameSourceGeoIP, attributes[visitorNameSourceKey])
assert.Equal(t, "kept", attributes["customer_note"])
}
func TestPublicWidgetAdditionalAttributesHidesVisitorMetadata(t *testing.T) {
public := PublicWidgetAdditionalAttributes(mustJSON(map[string]any{
visitorNameSourceKey: visitorNameSourceGeoIP,
visitorProvinceKey: "河北",
"created_at_ip": "203.0.113.10",
"customer_note": "kept",
}))
assert.Equal(t, map[string]any{"customer_note": "kept"}, public)
}
func TestWidgetService_SubmitOfflineMessageUsesGeoIPName(t *testing.T) {
db, svc := setupWidgetOfflineMessageTest(t)
account := createTestAccountForWidget(t, db)
inbox := createTestInboxForWidget(t, db, account.ID)
svc.SetVisitorGeoResolver(&fakeVisitorGeoResolver{
location: geoip.Location{Province: "河北省", City: "保定市"},
ok: true,
})
msg, err := svc.SubmitOfflineMessage(context.Background(), inbox.ID, account.ID, &model.WidgetOfflineMessageSubmission{
Message: "Need help",
ClientIP: "203.0.113.13",
}, "", "")
require.NoError(t, err)
assert.Equal(t, "河北保定客户", msg.ContactName)
}