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
+4 -1
View File
@@ -1,10 +1,13 @@
# GoChat production variables. Copy to .env and replace every CHANGE_ME value.
GOCHAT_IMAGE_REF=ghcr.io/rogeecn/gochat@sha256:CHANGE_ME
GOCHAT_IMAGE_REF=git.ipao.vip/rogee/gochat@sha256:CHANGE_ME
GOCHAT_ENV=production
GOCHAT_PORT=3000
GOCHAT_SERVER_MODE=release
GOCHAT_SERVER_TRUSTED_PROXIES=10.0.0.0/8
# Optional local MaxMind-compatible City database mounted by production Compose.
# GOCHAT_GEOIP_DB_PATH=/run/gochat/geoip/GeoLite2-City.mmdb
# GOCHAT_GEOIP_DB_FILE=../../.secrets/GeoLite2-City.mmdb
GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME@db.CHANGE_ME.example.com:5432/gochat_production?sslmode=verify-full
GOCHAT_REDIS_DSN=redis://redis:6379/1
+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)
}
+9 -3
View File
@@ -1,6 +1,8 @@
# GoChat Development Environment
# Reference: Chatwoot docker-compose.yaml pattern — rails + sidekiq + postgres + redis + mailhog + vite
# Enhanced with pgvector (for Captain AI vector search), volume mounts, hot reload
# Reference: Chatwoot docker-compose.yaml pattern — Rails, Sidekiq,
# PostgreSQL, Redis, Mailhog, and Vite.
# Enhanced with pgvector for Captain AI vector search, volume mounts,
# and hot reload.
version: '3.8'
@@ -29,7 +31,9 @@ services:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
command: >-
redis-server --appendonly yes --maxmemory 256mb
--maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@@ -63,6 +67,7 @@ services:
- GOCHAT_ENV=development
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_dev?sslmode=disable
- GOCHAT_REDIS_DSN=redis://redis:6379
- GOCHAT_GEOIP_DB_PATH=${GOCHAT_GEOIP_DB_PATH:-}
- SMTP_HOST=mailhog
- SMTP_PORT=1025
@@ -85,6 +90,7 @@ services:
- GOCHAT_ENV=development
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_dev?sslmode=disable
- GOCHAT_REDIS_DSN=redis://redis:6379
- GOCHAT_GEOIP_DB_PATH=${GOCHAT_GEOIP_DB_PATH:-}
volumes:
postgres_data:
+97 -30
View File
@@ -1,6 +1,7 @@
name: gochat-production
x-gochat-image: &gochat-image ${GOCHAT_IMAGE_REF:?set GOCHAT_IMAGE_REF to an immutable image digest}
x-gochat-image: &gochat-image >-
${GOCHAT_IMAGE_REF:?set GOCHAT_IMAGE_REF to an immutable image digest}
x-postgres-tls-ca: &postgres-tls-ca
type: bind
source: ${GOCHAT_DATABASE_TLS_CA_FILE:-/dev/null}
@@ -41,7 +42,8 @@ x-gochat-environment: &gochat-environment
GOCHAT_SERVER_HOST: 0.0.0.0
GOCHAT_SERVER_PORT: 3000
GOCHAT_SERVER_MODE: release
GOCHAT_DATABASE_DSN: &gochat-database-dsn ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with an explicit sslmode}
GOCHAT_DATABASE_DSN: &gochat-database-dsn >-
${GOCHAT_DATABASE_DSN:?set GOCHAT_DATABASE_DSN with an explicit sslmode}
GOCHAT_DATABASE_RUN_MIGRATIONS: "false"
GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations
GOCHAT_REDIS_DSN: ${GOCHAT_REDIS_DSN:?set an external Redis DSN}
@@ -51,16 +53,27 @@ x-gochat-environment: &gochat-environment
GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:?set GOCHAT_JWT_SECRET}
GOCHAT_JWT_PREVIOUS_SECRETS: ${GOCHAT_JWT_PREVIOUS_SECRETS:-}
GOCHAT_ENCRYPTION_ENABLED: "true"
GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION: ${GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION:-1}
GOCHAT_ENCRYPTION_AES_KEY: ${GOCHAT_ENCRYPTION_AES_KEY:?set a base64-encoded 32-byte encryption key}
GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION: >-
${GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION:-1}
GOCHAT_ENCRYPTION_AES_KEY: >-
${GOCHAT_ENCRYPTION_AES_KEY:?set a base64-encoded 32-byte encryption key}
GOCHAT_LOG_LEVEL: info
GOCHAT_LOG_FORMAT: json
GOCHAT_STORAGE_PROVIDER: local
GOCHAT_STORAGE_LOCAL_PATH: /app/storage/uploads
GOCHAT_GEOIP_DB_PATH: ${GOCHAT_GEOIP_DB_PATH:-}
x-gochat-geoip: &gochat-geoip
type: bind
source: ${GOCHAT_GEOIP_DB_FILE:-/dev/null}
target: /run/gochat/geoip/GeoLite2-City.mmdb
read_only: true
bind:
create_host_path: false
services:
meilisearch:
image: ${MEILI_IMAGE_REF:-getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae}
image: >-
${MEILI_IMAGE_REF:-getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae}
restart: always
environment:
MEILI_ENV: production
@@ -69,7 +82,12 @@ services:
volumes:
- ./data/meilisearch:/meili_data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://127.0.0.1:7700/health"]
test:
- "CMD"
- "wget"
- "--no-verbose"
- "--spider"
- "http://127.0.0.1:7700/health"
interval: 5s
timeout: 5s
retries: 20
@@ -94,8 +112,17 @@ services:
- *postgres-tls-ca
- *postgres-tls-client-cert
- *postgres-tls-client-key
- *gochat-geoip
healthcheck:
test: ["CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:3000/ready"]
test:
- "CMD"
- "wget"
- "-q"
- "-T"
- "3"
- "-O"
- "/dev/null"
- "http://127.0.0.1:3000/ready"
interval: 10s
timeout: 5s
start_period: 15s
@@ -133,6 +160,7 @@ services:
- *postgres-tls-ca
- *postgres-tls-client-cert
- *postgres-tls-client-key
- *gochat-geoip
deploy:
resources:
limits:
@@ -162,13 +190,16 @@ services:
image: *gochat-image
user: "0:0"
profiles: ["ops"]
entrypoint: ["/usr/local/bin/database-client-entrypoint", "/app/scripts/db_backup.sh"]
entrypoint:
- /usr/local/bin/database-client-entrypoint
- /app/scripts/db_backup.sh
group_add: ["${GOCHAT_DATABASE_TLS_GID:-65534}"]
restart: "no"
environment:
GOCHAT_DATABASE_DSN: *gochat-database-dsn
GOCHAT_STORAGE_PATH: /source/storage/uploads
GOCHAT_CONNECTOR_BACKUP_FILE: /source/connector/${GOCHAT_CONNECTOR_BACKUP_NAME:-latest.db}
GOCHAT_CONNECTOR_BACKUP_FILE: >-
/source/connector/${GOCHAT_CONNECTOR_BACKUP_NAME:-latest.db}
GOCHAT_BACKUP_DIR: /backup/local
GOCHAT_BACKUP_OFFSITE_DIR: /backup/offsite
GOCHAT_BACKUP_PASSPHRASE_FILE: /run/secrets/backup-passphrase
@@ -180,7 +211,8 @@ services:
- ./data/connector-backups:/source/connector:ro
- ${GOCHAT_BACKUP_DIR:-./data/backups/local}:/backup/local
- type: bind
source: ${GOCHAT_BACKUP_OFFSITE_DIR:?set an existing external off-site mount point}
source: >-
${GOCHAT_BACKUP_OFFSITE_DIR:?set GOCHAT_BACKUP_OFFSITE_DIR}
target: /backup/offsite
bind:
create_host_path: false
@@ -196,7 +228,9 @@ services:
image: *gochat-image
user: "0:0"
profiles: ["ops"]
entrypoint: ["/usr/local/bin/database-client-entrypoint", "/app/scripts/db_restore.sh"]
entrypoint:
- /usr/local/bin/database-client-entrypoint
- /app/scripts/db_restore.sh
group_add: ["${GOCHAT_DATABASE_TLS_GID:-65534}"]
command: ["/backup/offsite/${GOCHAT_RESTORE_BUNDLE:-missing.tar.enc}"]
restart: "no"
@@ -209,12 +243,14 @@ services:
- ./data/storage:/restore/storage
- ./data/connector:/restore/connector
- type: bind
source: ${GOCHAT_BACKUP_OFFSITE_DIR:?set an existing external off-site mount point}
source: >-
${GOCHAT_BACKUP_OFFSITE_DIR:?set GOCHAT_BACKUP_OFFSITE_DIR}
target: /backup/offsite
read_only: true
bind:
create_host_path: false
- ${GOCHAT_BACKUP_PASSPHRASE_FILE:-./.secrets/backup-passphrase}:/run/secrets/backup-passphrase:ro
- >-
${GOCHAT_BACKUP_PASSPHRASE_FILE:-./.secrets/backup-passphrase}:/run/secrets/backup-passphrase:ro
- *database-client-entrypoint
- *postgres-tls-ca
- *postgres-tls-client-cert
@@ -222,7 +258,8 @@ services:
logging: *gochat-logging
shangwutong:
image: ${SHANGWUTONG_IMAGE_REF:?set SHANGWUTONG_IMAGE_REF to an immutable image digest}
image: >-
${SHANGWUTONG_IMAGE_REF:?set SHANGWUTONG_IMAGE_REF}
restart: always
stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s}
environment:
@@ -238,7 +275,15 @@ services:
- ./data/connector:/data
- ./data/connector-backups:/backup
healthcheck:
test: ["CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:9100/readyz"]
test:
- "CMD"
- "wget"
- "-q"
- "-T"
- "3"
- "-O"
- "/dev/null"
- "http://127.0.0.1:9100/readyz"
interval: 30s
timeout: 5s
start_period: 15s
@@ -254,7 +299,8 @@ services:
logging: *gochat-logging
fluentd:
image: fluent/fluentd:v1.18-debian-1@sha256:f8d26db76ba06ce96e8d402119675071624dab724af49be40fd34641c347c440
image: >-
fluent/fluentd:v1.18-debian-1@sha256:f8d26db76ba06ce96e8d402119675071624dab724af49be40fd34641c347c440
restart: always
ports:
- "127.0.0.1:24224:24224"
@@ -264,11 +310,16 @@ services:
- ./data/fluentd/buffer:/fluentd/buffer
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:v0.17.1@sha256:38606faa38c54787525fb0ff2fd6b41b4cfb75d455c1df294927c5f611699b17
image: >-
quay.io/prometheuscommunity/postgres-exporter:v0.17.1@sha256:38606faa38c54787525fb0ff2fd6b41b4cfb75d455c1df294927c5f611699b17
restart: always
entrypoint: ["/usr/local/bin/database-client-entrypoint", "/bin/postgres_exporter"]
entrypoint:
- /usr/local/bin/database-client-entrypoint
- /bin/postgres_exporter
group_add: ["${GOCHAT_DATABASE_TLS_GID:-65534}"]
command: ["--config.file=/dev/null", "--extend.query-path=/etc/postgres-exporter/queries.yml"]
command:
- --config.file=/dev/null
- --extend.query-path=/etc/postgres-exporter/queries.yml
environment:
DATA_SOURCE_NAME: *gochat-database-dsn
volumes:
@@ -280,14 +331,16 @@ services:
logging: *gochat-logging
redis-exporter:
image: oliver006/redis_exporter:v1.72.1@sha256:f90cae1e7ecc6ac223d04bdb0c95e084918baced9f81f08c3d01c2f11bff72bf
image: >-
oliver006/redis_exporter:v1.72.1@sha256:f90cae1e7ecc6ac223d04bdb0c95e084918baced9f81f08c3d01c2f11bff72bf
restart: always
environment:
REDIS_ADDR: ${GOCHAT_REDIS_DSN:?set an external Redis DSN}
logging: *gochat-logging
blackbox-exporter:
image: prom/blackbox-exporter:v0.27.0@sha256:a50c4c0eda297baa1678cd4dc4712a67fdea713b832d43ce7fcc5f9bea05094d
image: >-
prom/blackbox-exporter:v0.27.0@sha256:a50c4c0eda297baa1678cd4dc4712a67fdea713b832d43ce7fcc5f9bea05094d
restart: always
command: ["--config.file=/etc/blackbox_exporter/config.yml"]
volumes:
@@ -295,15 +348,20 @@ services:
logging: *gochat-logging
node-exporter:
image: prom/node-exporter:v1.9.1@sha256:d00a542e409ee618a4edc67da14dd48c5da66726bbd5537ab2af9c1dfc442c8a
image: >-
prom/node-exporter:v1.9.1@sha256:d00a542e409ee618a4edc67da14dd48c5da66726bbd5537ab2af9c1dfc442c8a
restart: always
command: ["--collector.disable-defaults", "--collector.textfile", "--collector.textfile.directory=/var/lib/node_exporter/textfile_collector"]
command:
- --collector.disable-defaults
- --collector.textfile
- --collector.textfile.directory=/var/lib/node_exporter/textfile_collector
volumes:
- ./data/backup-metrics:/var/lib/node_exporter/textfile_collector:ro
logging: *gochat-logging
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1@sha256:f40e65878e25c2e78ea037f73a449527a0fb994e303dc3e34cb6b187b4b91435
image: >-
gcr.io/cadvisor/cadvisor:v0.52.1@sha256:f40e65878e25c2e78ea037f73a449527a0fb994e303dc3e34cb6b187b4b91435
restart: always
privileged: true
devices:
@@ -317,15 +375,19 @@ services:
logging: *gochat-logging
alertmanager:
image: prom/alertmanager:v0.28.1@sha256:27c475db5fb156cab31d5c18a4251ac7ed567746a2483ff264516437a39b15ba
image: >-
prom/alertmanager:v0.28.1@sha256:27c475db5fb156cab31d5c18a4251ac7ed567746a2483ff264516437a39b15ba
restart: always
command: ["--config.file=/etc/alertmanager/alertmanager.yml", "--storage.path=/alertmanager"]
command:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
ports:
- "127.0.0.1:${ALERTMANAGER_PORT:-9093}:9093"
volumes:
- ../prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- type: bind
source: ${ALERTMANAGER_WEBHOOK_URL_FILE:-../../.secrets/alertmanager-webhook-url}
source: >-
${ALERTMANAGER_WEBHOOK_URL_FILE:-../../.secrets/alertmanager-webhook-url}
target: /run/secrets/alertmanager-webhook-url
read_only: true
bind:
@@ -334,7 +396,8 @@ services:
logging: *gochat-logging
prometheus:
image: prom/prometheus:v3.5.0@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996
image: >-
prom/prometheus:v3.5.0@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996
restart: always
depends_on:
- alertmanager
@@ -343,11 +406,15 @@ services:
- node-exporter
- postgres-exporter
- redis-exporter
command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-30d}"]
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-30d}
ports:
- "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ../prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ../../backend/configs/prometheus_alerts.yml:/etc/prometheus/rules/gochat.yml:ro
- >-
../../backend/configs/prometheus_alerts.yml:/etc/prometheus/rules/gochat.yml:ro
- ./data/prometheus:/prometheus
logging: *gochat-logging
+2
View File
@@ -76,6 +76,7 @@ services:
- GOCHAT_ENV=development
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_dev?sslmode=disable
- GOCHAT_REDIS_DSN=redis://redis:6379
- GOCHAT_GEOIP_DB_PATH=${GOCHAT_GEOIP_DB_PATH:-}
volumes:
- ../../backend:/app:delegated
@@ -96,6 +97,7 @@ services:
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_dev?sslmode=disable
- GOCHAT_DATABASE_RUN_MIGRATIONS=false
- GOCHAT_REDIS_DSN=redis://redis:6379
- GOCHAT_GEOIP_DB_PATH=${GOCHAT_GEOIP_DB_PATH:-}
volumes:
- ../../backend:/app:delegated
command: ["worker"]
+4
View File
@@ -20,6 +20,10 @@ REDIS_PASSWORD=
# GoChat runtime
GOCHAT_ENV=development
GOCHAT_SERVER_MODE=debug
# Optional local MaxMind-compatible City database. Leave empty for anonymous fallback.
GOCHAT_GEOIP_DB_PATH=
# Host path relative to deploy/quickstart when GOCHAT_GEOIP_DB_PATH is set.
GOCHAT_GEOIP_DB_FILE=../../.secrets/GeoLite2-City.mmdb
GOCHAT_JWT_SECRET=gochat_quickstart_change_me_minimum_32_chars
# Optional Captain/Copilot runtime provider. Secrets stay in this ignored .env
+38 -6
View File
@@ -1,5 +1,13 @@
name: gochat-quickstart
x-gochat-geoip: &gochat-geoip
type: bind
source: ${GOCHAT_GEOIP_DB_FILE:-/dev/null}
target: /run/gochat/geoip/GeoLite2-City.mmdb
read_only: true
bind:
create_host_path: false
services:
postgres:
image: pgvector/pgvector:pg16
@@ -34,7 +42,11 @@ services:
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "if [ -n \"$${REDIS_PASSWORD}\" ]; then redis-cli -a \"$${REDIS_PASSWORD}\" ping; else redis-cli ping; fi"]
test:
- CMD-SHELL
- >-
if [ -n "$${REDIS_PASSWORD}" ]; then redis-cli -a
"$${REDIS_PASSWORD}" ping; else redis-cli ping; fi
interval: 5s
timeout: 5s
retries: 20
@@ -51,7 +63,12 @@ services:
volumes:
- meili-data:/meili_data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://127.0.0.1:7700/health"]
test:
- CMD
- wget
- --no-verbose
- --spider
- http://127.0.0.1:7700/health
interval: 5s
timeout: 5s
retries: 30
@@ -81,7 +98,9 @@ services:
GOCHAT_SERVER_HOST: 0.0.0.0
GOCHAT_SERVER_PORT: 3000
GOCHAT_SERVER_MODE: ${GOCHAT_SERVER_MODE:-debug}
GOCHAT_DATABASE_DSN: postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:-gochat_dev}@postgres:5432/${POSTGRES_DB:-gochat_dev}?sslmode=disable
GOCHAT_DATABASE_DSN: "postgres://${POSTGRES_USER:-gochat}:\
${POSTGRES_PASSWORD:-gochat_dev}@postgres:5432/\
${POSTGRES_DB:-gochat_dev}?sslmode=disable"
GOCHAT_DATABASE_RUN_MIGRATIONS: "true"
GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations
GOCHAT_REDIS_DSN: ${REDIS_DSN:-redis://redis:6379}
@@ -89,11 +108,12 @@ services:
GOCHAT_SEARCH_ENGINE: meilisearch
GOCHAT_SEARCH_HOST: http://meilisearch:7700
GOCHAT_SEARCH_API_KEY: ${GOCHAT_SEARCH_API_KEY:-gochat_dev}
GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:-gochat_quickstart_change_me_minimum_32_chars}
GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:-gochat_quickstart_secret_32_chars}
GOCHAT_LOG_LEVEL: info
GOCHAT_LOG_FORMAT: json
GOCHAT_STORAGE_PROVIDER: local
GOCHAT_STORAGE_LOCAL_PATH: /app/storage/uploads
GOCHAT_GEOIP_DB_PATH: ${GOCHAT_GEOIP_DB_PATH:-}
GOCHAT_COPILOT_PROVIDER_CONFIG: ${GOCHAT_COPILOT_PROVIDER_CONFIG:-}
GOCHAT_COPILOT_CHAT_API_KEY: ${GOCHAT_COPILOT_CHAT_API_KEY:-}
GOCHAT_COPILOT_EMBEDDING_API_KEY: ${GOCHAT_COPILOT_EMBEDDING_API_KEY:-}
@@ -103,6 +123,7 @@ services:
- "127.0.0.1:${GOCHAT_PORT:-3000}:3000"
volumes:
- gochat-storage:/app/storage
- *gochat-geoip
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/ready"]
interval: 10s
@@ -129,6 +150,7 @@ services:
GOCHAT_DATABASE_RUN_MIGRATIONS: "false"
volumes:
- gochat-storage:/app/storage
- *gochat-geoip
command: ["worker"]
healthcheck:
disable: true
@@ -147,7 +169,9 @@ services:
GOCHAT_SEED_ADMIN_PASSWORD: ${GOCHAT_SEED_ADMIN_PASSWORD:-changeme}
GOCHAT_SEED_ADMIN_NAME: ${GOCHAT_SEED_ADMIN_NAME:-Super Admin}
GOCHAT_SEED_ACCOUNT_NAME: ${GOCHAT_SEED_ACCOUNT_NAME:-Quickstart Account}
GOCHAT_SEED_INBOX_NAME: ${GOCHAT_SEED_INBOX_NAME:-Quickstart Website Inbox}
GOCHAT_SEED_INBOX_NAME: >-
${GOCHAT_SEED_INBOX_NAME:-Quickstart Website
Inbox}
volumes:
- gochat-storage:/app/storage
command: ["seed"]
@@ -173,7 +197,15 @@ services:
- shangwutong-data:/data
- shangwutong-backups:/backup
healthcheck:
test: ["CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:9100/readyz"]
test:
- CMD
- wget
- -q
- -T
- "3"
- -O
- /dev/null
- http://127.0.0.1:9100/readyz
interval: 10s
timeout: 5s
retries: 30
+1 -1
View File
@@ -30,7 +30,7 @@ separate node-local attachment directories.
Set these operator-owned paths before any Compose command:
```bash
export GOCHAT_IMAGE_REF='ghcr.io/gochat/gochat@sha256:<digest>'
export GOCHAT_IMAGE_REF='git.ipao.vip/rogee/gochat@sha256:<digest>'
export GOCHAT_BACKUP_DIR='/mnt/backup-local/gochat'
export GOCHAT_BACKUP_OFFSITE_DIR='/mnt/gochat-offsite'
export GOCHAT_BACKUP_OFFSITE_SOURCE='backup.example.com:/gochat'
@@ -0,0 +1,406 @@
# 匿名访客 IP 归属地客户名称:落地开发计划
> 日期:2026-09-14
> 状态:**已实施并验证**
> 需求确认:匿名 Web Widget 访客不再统一显示 `Anonymous Visitor`,而是根据服务端获取的客户端 IP 查询省市,展示为“省市客户”,例如 `河北保定客户``北京客户`
## 1. 目标与范围
### 1.1 目标
当 Web Widget 访客未提供真实姓名时:
1. 由 GoChat 服务端获取请求的真实客户端 IP;
2. 使用本地 GeoIP 数据库查询省、市;
3. 生成联系人显示名:
- 省 + 市:`河北保定客户`
- 直辖市或仅有城市:`北京客户`
- 仅有省份:`河北客户`
- 无法识别:`匿名客户`
4. 真实姓名、邮箱、电话或已认证标识优先,不能被 IP 地区名称覆盖;
5. 名称继续写入现有 `contacts.name`,使会话列表、会话头部、联系人列表和搜索结果自动复用。
### 1.2 一期范围
- Web Widget 的 `/widget/init``/api/v1/widget/config` 匿名联系人创建/重新初始化;
- Web Widget 公开联系人创建路径中确实会创建匿名联系人的场景;
- IPv4、IPv6、内网/保留地址、无 GeoIP 记录等降级场景;
- 既有匿名联系人在下次 Widget 初始化时的兼容处理;
- 本地 GeoIP 数据库配置、容器挂载、启动检查和回归测试。
### 1.3 不在一期范围
- 浏览器定位权限、GPS 或 HTML5 Geolocation
- 第三方在线 IP 查询 API
- 手工联系人、邮箱、短信、商务通等非 Web Widget 渠道;
- 通过 IP 判断客户精确住址、公司地址或真实所在地;
- 历史联系人批量回填。当前后端没有完整保存历史 Widget 客户 IP,无法可靠回填;
- 新增联系人表省市字段、后台配置页面或独立地区筛选功能。
## 2. 已确认的现状
| 位置 | 当前行为 | 计划处理 |
| --- | --- | --- |
| `backend/internal/service/widget_service.go:1735` | 匿名联系人名称硬编码为 `Anonymous Visitor` | 改为调用地区名称生成逻辑 |
| `backend/internal/service/widget_service.go:293` | Widget 初始化只接收 `WidgetInitRequest`,未接收服务端 IP | 在内部请求上下文中注入服务端取得的 IP |
| `backend/internal/handler/widget/widget_handler.go:Init` | 未把 `c.ClientIP()` 传给 Widget Service | 仅由服务端设置内部字段,禁止 JSON 覆盖 |
| `backend/internal/handler/widget/widget_handler.go:Config` | 也可能触发联系人初始化 | 与 `Init` 使用同一套 IP 处理 |
| `backend/internal/model/contact.go` | 已有 `AdditionalAttributes` JSON 字段 | 保存必要的生成来源/地区元数据,不新增迁移 |
| `frontend/.../ConversationCard.vue``ConversationHeader.vue``ContactInfo.vue` | 均展示 `contact.name` | 一期无需改展示组件 |
| `frontend/.../ConversationInfo.vue` | 若存在 `created_at_ip` 会展示原始 IP | 一期默认不保存原始 IP,避免扩大隐私暴露 |
| `backend/internal/config/config.go` | 已有 Viper 配置和 `GOCHAT_*` 环境变量绑定机制 | 增加 GeoIP 数据库路径配置 |
| `deploy/docker/Dockerfile` | 生产镜像不携带第三方 GeoIP 数据 | 数据库通过只读 Volume 挂载,不提交到仓库/镜像 |
## 3. 产品行为契约
### 3.1 名称优先级
从高到低:
1. 访客主动提交的真实姓名;
2. 已存在联系人中的人工维护姓名;
3. 本次 IP 查询生成的省市名称;
4. `匿名客户`
系统不得根据 IP 覆盖非空且非系统生成的真实姓名。
### 3.2 系统生成名称识别
使用 `AdditionalAttributes` 保存以下服务端管理属性:
```json
{
"visitor_name_source": "ip_geolocation",
"visitor_province": "河北",
"visitor_city": "保定",
"visitor_geo_at": "2026-09-14T12:00:00Z"
}
```
这些字段用于判断名称是否由系统生成、兼容旧的 `Anonymous Visitor`、保存地区审计信息。它们不是访客可提交的可信字段。
服务端管理字段:
- 不接受 Widget 客户端在 `additional_attributes` 中覆盖;
- 不记录原始 IP 到联系人属性;
- 不写入应用日志;
- 对外公开 Widget 响应时按现有响应契约过滤服务端内部元数据,避免把内部标记返回给访客。
### 3.3 重新初始化规则
- 新匿名联系人:根据当前 IP 生成名称;
- 旧名称为 `Anonymous Visitor``匿名客户`:下次初始化时允许升级为省市名称;
- 旧名称为系统生成的省市名称:保留名称,地区元数据可按当前策略更新;
- 旧名称由访客或客服设置:不覆盖;
- 无法解析 IP:不清空已有名称,新的联系人使用 `匿名客户`
## 4. 技术方案
### 4.1 IP 获取
在 Widget Handler 层使用 Gin 的 `c.ClientIP()` 获取 IP,并传入服务层的内部字段,例如:
```go
ClientIP string `json:"-"`
```
要求:
- `json:"-"` 防止客户端通过请求体伪造;
- 不读取客户端自定义 `visitor_ip``client_ip` 等字段;
- `X-Forwarded-For` 只在 `server.trusted_proxies` 已正确配置时使用;
- 本地开发、内网和解析失败时安全降级,不阻断访客发消息。
需要覆盖的入口:
- `WidgetHandler.Init`
- `WidgetHandler.Config`
- 公开 Web Widget 联系人创建入口;
- 离线留言若在提交阶段创建/保存联系人信息,则在该链路中保留统一的地区处理边界,避免遗漏匿名离线客户。
服务层不直接依赖 Gin,继续通过内部 DTO 传递已验证的 IP。
### 4.2 本地 GeoIP
采用本地 MaxMind-compatible City MMDB 文件和 Go 读取库:
- 不在每次请求中调用第三方 HTTP API;
- 不把访客 IP 发送给第三方服务;
- 查询为本地内存读取,失败时直接降级;
- MMDB 文件不进入 Git、不写入容器镜像,由部署环境提供。
建议配置:
```yaml
geoip:
db_path: "/app/storage/geoip/GeoLite2-City.mmdb"
```
环境变量:
```text
GOCHAT_GEOIP_DB_PATH=/app/storage/geoip/GeoLite2-City.mmdb
```
配置行为:
- 路径为空:GeoIP 功能关闭,名称回退为 `匿名客户`
- 文件不存在或无法读取:启动时记录一次告警,服务仍可启动;
- 文件格式错误:不阻断客户请求,使用匿名回退,不泄漏 IP;
- 服务停止时关闭 MMDB reader
- 使用单个共享 reader,避免每个请求重复打开文件。
实现时先使用测试 IP 样本确认数据库能返回中国省、市及可用的中文名称;若只返回英文名称,增加有限的省市别名格式化,不在请求链路接入翻译服务。
### 4.3 地区名称格式化
新增最小格式化逻辑:
```text
province + city + "客户"
```
规则:
- 城市和省份相同或城市已包含省份时去重;
- 直辖市输出 `北京客户`,不输出 `北京北京市客户`
- 城市为空时输出 `省份客户`
- 省、市均为空时输出 `匿名客户`
- 不把县、街道、经纬度等更细粒度信息放入名称;
- 保持名称长度在联系人字段限制内。
### 4.4 联系人写入
优先复用现有 `findOrCreateWidgetContact` 和联系人更新逻辑:
1. 先按邮箱、标识或电话查找已有联系人;
2. 已找到联系人时,判断姓名是否为系统生成;
3. 未找到联系人时,生成地区名称后创建;
4. 在写入前合并服务端地区元数据;
5. 通过现有 Contact Repo 保存,确保联系人名称进入已有搜索/序列化路径。
不新增联系人表字段,不新增第二套联系人名称字段。
如既有联系人名称发生自动升级,需要确认已有联系人事件/搜索索引机制是否会被触发;若现有 Repo 更新不会触发相应事件,则只在联系人读取时使用已有名称,不另建异步索引框架。
### 4.5 公开响应与安全
现有 `widgetContactFullPayload` 会返回联系人名称及附加属性。实现时必须:
- 不返回原始 IP
- 不返回 `visitor_name_source` 等内部控制字段;
- 不允许客户端通过更新联系人接口覆盖服务端地区字段;
- 保留联系人名称的正常 Widget 契约,不影响访客继续提交真实姓名;
- 不根据 IP 自动改变联系人邮箱、电话、标识或认证状态。
## 5. 文件与代码变更计划
### 阶段 AGeoIP 基础能力
预计涉及:
- `backend/go.mod`
- `backend/go.sum`
- `backend/internal/geoip/`(新增最小 reader/lookup/format 实现)
- `backend/internal/config/config.go`
- `backend/configs/config.yaml`
- `backend/internal/config/config_test.go`
工作内容:
- 增加 MMDB reader
- 增加 IPv4/IPv6、空地址、内网地址和无记录处理;
- 增加中文名称/英文回退及省市格式化;
- 增加配置解析和缺失文件降级测试;
- 不实现远程 API、缓存服务或管理页面。
退出条件:GeoIP reader 可以独立测试,数据库不可用不会导致应用启动失败。
### 阶段 BWidget 服务端接入
预计涉及:
- `backend/internal/service/widget_service.go`
- `backend/internal/handler/widget/widget_handler.go`
- `backend/internal/app/bootstrap.go`
- `backend/internal/model/contact.go`(仅在确有必要时调整属性保护辅助函数)
- 对应服务/Handler 测试文件
工作内容:
- 将服务端 IP 注入 `WidgetInitRequest` 内部字段;
- 在 `Init``Config` 及纳入一期的公开 Widget 联系人入口接入;
- 替换 `Anonymous Visitor` 创建逻辑;
- 保留真实姓名优先;
- 保护服务端地区属性;
- 启动时创建共享 GeoIP reader,注入 Widget Service
- 缺失 GeoIP 数据时回退,不阻塞聊天。
退出条件:匿名新访客可得到省市名称,真实姓名和已识别联系人不被覆盖。
### 阶段 C:离线路径与兼容处理
预计涉及:
- `backend/internal/service/widget_service.go` 的离线留言转换逻辑;
- `backend/internal/handler/widget/widget_handler.go` 的离线留言入口;
- `backend/internal/model/widget_offline_message.go` 或对应迁移,仅在 IP/地区必须跨异步转换保存时增加字段;
- 离线消息相关测试。
优先采用无迁移方案:在离线留言提交时完成地区解析并保存必要的非敏感地区结果。如果现有离线消息表无法保存该结果,再评估增加最小字段及 up/down migration,不直接保存原始 IP。
退出条件:离线留言转换出的匿名联系人不会重新退回空名称或 `Anonymous Visitor`
### 阶段 D:部署与文档
预计涉及:
- `deploy/docker/docker-compose.yml`
- `deploy/docker/docker-compose.dev.yml`
- `deploy/docker/docker-compose.prod.yml`
- `deploy/quickstart/compose.yaml`(如 Quickstart 需要演示)
- `.env.example` 或对应环境示例;
- `docs/runbooks/` 中的部署说明。
工作内容:
- 为 GoChat 容器提供只读 GeoIP 数据库挂载点;
- 增加 `GOCHAT_GEOIP_DB_PATH` 示例;
- 明确 MMDB 下载、授权、更新和权限要求;
- 不将数据库文件提交仓库或 COPY 进镜像;
- 没有数据库时保持兼容运行。
## 6. 测试与验收矩阵
### 6.1 GeoIP 单元测试
| 场景 | 期望 |
| --- | --- |
| 有省、市的 IPv4 | 生成 `河北保定客户` |
| 直辖市 | 生成 `北京客户` |
| 只有省份 | 生成 `河北客户` |
| IPv6 | 可查询则正常生成,否则安全回退 |
| `127.0.0.1`、私网、保留地址 | 不调用无意义查询,回退 |
| 空 IP/非法 IP | 回退,不报 500 |
| 无 MMDB 文件 | 服务可启动,回退 |
| 中文名称缺失 | 使用确定性的名称回退/别名,不请求翻译服务 |
### 6.2 Widget 服务测试
新增或补充 `backend/internal/service/widget_service_test.go`
- 无姓名 + 有地区 → 创建省市客户;
- 无姓名 + 无 GeoIP → 创建 `匿名客户`
- 有真实姓名 + 有地区 → 保留真实姓名;
- 既有人工联系人 + 新 IP → 不改名;
- 既有 `Anonymous Visitor` → 下次初始化可升级;
- 既有系统生成名称 → 不被空查询清空;
- 邮箱/电话/identifier 命中已有联系人时不产生错误重命名;
- 客户传入伪造地区属性时,服务端字段不被覆盖;
- 不保存原始 IP。
### 6.3 Handler/API 测试
新增或补充 `backend/internal/handler/widget/widget_handler_test.go`
- 请求体中的 `client_ip``visitor_ip` 不影响最终结果;
- `c.ClientIP()` 获取的地址能传入服务层;
- 未配置可信代理时伪造 `X-Forwarded-For` 不被采信;
- 配置可信代理后按现有 Gin 规则取得客户端 IP;
- `/widget/init``/api/v1/widget/config` 行为一致;
- 公开响应不包含原始 IP和内部地区控制字段;
- 离线消息路径按最终纳入范围验证。
### 6.4 前端验收
一期不改 Vue 展示组件,因为现有组件已经统一使用 `contact.name`。验收重点:
- 会话列表显示 `河北保定客户`
- 会话头部显示 `北京客户`
- 联系人搜索结果使用新名称;
- 客服手工改名后刷新页面仍保留真实姓名;
- 不因联系人附加属性变化破坏既有 ContactInfo/ConversationInfo 渲染。
### 6.5 构建和回归
```bash
cd backend
go test ./internal/...
go vet ./...
go build ./...
# 如修改了前端或执行完整前端回归
cd ../frontend
pnpm test -- --run
pnpm build
```
同时执行:
- 配置 YAML 解析检查;
- Compose 配置检查;
- Git diff 检查,确认没有提交 MMDB、`.env` 或原始 IP 测试数据;
- 使用隔离测试账号完成一次 Widget API → 联系人 → 会话列表显示验证。
## 7. 部署与回滚
### 7.1 发布前条件
- 生产环境已准备合法、可读的 GeoIP MMDB 文件;
- `GOCHAT_GEOIP_DB_PATH` 指向容器内只读路径;
- `server.trusted_proxies` 配置准确,不能使用任意代理网段;
- 隐私政策/数据保留规则确认地区推断用途;
- 至少验证一个中国省市、一个直辖市、一个无结果地址和 IPv6/私网降级场景。
### 7.2 灰度方式
1. 先发布带代码但不挂载 GeoIP 文件的镜像,确认服务仍正常;
2. 挂载测试数据库,在隔离 Widget 上验证名称;
3. 再对目标生产环境挂载正式数据库;
4. 观察匿名联系人创建失败率、Widget Init 错误率和联系人更新异常;
5. 如 GeoIP 文件异常,移除挂载或清空路径即可回退到 `匿名客户`,无需数据库回滚。
不新增高基数 IP 指标,不在日志中打印完整 IP。
## 8. 风险与边界
| 风险 | 处理 |
| --- | --- |
| VPN、代理、移动网络导致地区不准 | 名称明确代表 IP 归属地,不宣称客户精确位置 |
| MMDB 数据过期 | 在部署 runbook 中规定更新;文件不可用时安全回退 |
| 中国省市中文名称缺失 | 验证数据源 locale,使用有限别名格式化;不引入在线翻译 |
| 反向代理错误传递 IP | 只信任明确配置的代理,默认不信任 XFF |
| 客户伪造地区属性 | 服务端字段保护,客户端属性不能覆盖 |
| 真实姓名被覆盖 | 只处理空名、历史通用匿名名或系统生成名 |
| 历史联系人无法回填 | 只在下次 Widget 初始化时兼容升级,不做猜测性批量修改 |
| IP 属于个人信息 | 一期默认不持久化原始 IP,不向公开 Widget 返回内部元数据 |
## 9. 完成定义
满足以下条件才标记完成:
- [x] 匿名 Widget 新联系人可以按本地 GeoIP 生成省市客户名称;
- [x] `Anonymous Visitor`、无 GeoIP、内网 IP均有稳定回退;
- [x] 真实姓名、人工改名和已识别联系人不会被覆盖;
- [x] 服务端 IP来源不可由客户端请求体伪造;
- [x] 原始 IP不落库、不进日志、不通过公开 Widget 响应泄露;
- [x] 缺少/损坏 MMDB 不影响 Widget 初始化和消息发送;
- [x] 会话列表、会话头部、联系人搜索显示名称一致;
- [x] 后端测试、vet、build、配置/Compose检查通过;
- [x] 测试环境完成 API 到 Dashboard 的真实链路验证;
- [x] 部署说明包含 MMDB来源、挂载、更新和回滚方法。
## 10. 验证记录
- `GOCHAT_TEST_DB=sqlite go test ./...` 通过;
- `GOCHAT_TEST_DB=sqlite go test -race ./...` 通过;
- `go vet ./...``go build ./...` 通过;
- Frontend `pnpm build` 通过;
- 基础、开发、生产和 Quickstart Compose 配置解析通过;
- GeoIP、Widget Service、Widget Handler、公开联系人、离线留言和配置回归测试通过;
- 使用本地代码启动 Backend + Vite,通过 Widget API 创建匿名会话,并在 Dashboard 会话详情中验证 `匿名客户` 与测试消息可见;
- 中国省市/直辖市生成路径由 fake resolver 单元测试覆盖,正式 MMDB 不进入仓库,按 `docs/runbooks/anonymous-visitor-geoip.md` 外部挂载。
## 11. 明确跳过的复杂度
本计划不建设在线 IP 查询服务、独立地理位置微服务、联系人表新字段、后台 GeoIP 管理页面、历史联系人批量回填、GPS 定位和复杂缓存。只有在 GeoIP 查询量、地区筛选或数据保留要求证明现有 JSON 属性不足时,才单独增加这些能力。
+48
View File
@@ -0,0 +1,48 @@
# Anonymous Web Widget GeoIP 配置
GoChat 可使用本地 MaxMind-compatible City MMDB,将无姓名的 Web Widget 联系人命名为省市客户,例如 `河北保定客户`
## 配置
生产 Compose 使用两个变量:
```dotenv
GOCHAT_GEOIP_DB_PATH=/run/gochat/geoip/GeoLite2-City.mmdb
GOCHAT_GEOIP_DB_FILE=../../.secrets/GeoLite2-City.mmdb
```
`GOCHAT_GEOIP_DB_FILE` 是宿主机上的 MMDB 文件,`GOCHAT_GEOIP_DB_PATH` 是容器内的只读挂载路径。生产 Compose 会把文件挂载到 GoChat 和 worker 容器;不要把数据库文件提交到 Git 或打包进镜像。
开发和 Quickstart 默认不启用 GeoIP。需要验证时,设置 `GOCHAT_GEOIP_DB_PATH` 并把数据库挂载到对应容器内路径。
## 数据库来源和更新
使用具备合法授权的 MaxMind-compatible City 数据库。下载、授权、更新频率和访问权限由部署方负责;数据库文件应由运行用户可读、不可写。
更新步骤:
1. 在宿主机下载并校验新的 MMDB 文件;
2. 原子替换 `GOCHAT_GEOIP_DB_FILE` 指向的文件;
3. 重启 `gochat``worker`,让进程重新打开数据库;
4. 查看启动日志中的 `GeoIP database opened`
5. 用隔离 Widget 验证一个省市、一个直辖市和一个无记录地址。
## 故障回退
数据库缺失、损坏、过期或无法读取不会阻断服务启动或 Widget 请求。服务会记录一次启动告警,匿名联系人使用 `匿名客户`
临时关闭功能:
```dotenv
GOCHAT_GEOIP_DB_PATH=
```
然后重新部署或重启服务即可,无需数据库迁移或数据回滚。
## 隐私边界
- IP 只在服务端请求处理期间用于本地查询;
- 一期不把原始 IP 写入联系人、日志或公开 Widget 响应;
- 只保存系统生成名称所需的省、市元数据;
- IP 归属地是网络出口的近似位置,不能代表客户精确所在地;
- 反向代理场景必须正确配置 `server.trusted_proxies`,否则不要信任 `X-Forwarded-For`