diff --git a/.env.example b/.env.example index 94d21ed1..0a4d7ad3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/configs/config.production.yaml b/backend/configs/config.production.yaml index 02bc8466..02505a30 100644 --- a/backend/configs/config.production.yaml +++ b/backend/configs/config.production.yaml @@ -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: "" diff --git a/backend/configs/config.yaml b/backend/configs/config.yaml index 4b96768f..c8b9c7c9 100644 --- a/backend/configs/config.yaml +++ b/backend/configs/config.yaml @@ -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" diff --git a/backend/go.mod b/backend/go.mod index 2e9be8d2..a107a14d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index fbf04202..d13b445f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 75b93422..b13eaa27 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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 diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index ad06881a..c0261d7e 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -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()) diff --git a/backend/internal/app/shutdown.go b/backend/internal/app/shutdown.go index f808dd3c..49e3d85a 100644 --- a/backend/internal/app/shutdown.go +++ b/backend/internal/app/shutdown.go @@ -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 { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 143faaee..398f7d29 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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", "") diff --git a/backend/internal/config/geoip_test.go b/backend/internal/config/geoip_test.go new file mode 100644 index 00000000..92d255ad --- /dev/null +++ b/backend/internal/config/geoip_test.go @@ -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) + } +} diff --git a/backend/internal/geoip/geoip.go b/backend/internal/geoip/geoip.go new file mode 100644 index 00000000..a5fc66f5 --- /dev/null +++ b/backend/internal/geoip/geoip.go @@ -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 "" +} diff --git a/backend/internal/geoip/geoip_test.go b/backend/internal/geoip/geoip_test.go new file mode 100644 index 00000000..90d6b4b3 --- /dev/null +++ b/backend/internal/geoip/geoip_test.go @@ -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) + } +} diff --git a/backend/internal/handler/widget/widget_geolocation_test.go b/backend/internal/handler/widget/widget_geolocation_test.go new file mode 100644 index 00000000..3fb2f867 --- /dev/null +++ b/backend/internal/handler/widget/widget_geolocation_test.go @@ -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") +} diff --git a/backend/internal/handler/widget/widget_handler.go b/backend/internal/handler/widget/widget_handler.go index 9f3886f3..02e94fc4 100644 --- a/backend/internal/handler/widget/widget_handler.go +++ b/backend/internal/handler/widget/widget_handler.go @@ -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), } } diff --git a/backend/internal/handler/widget/widget_handler_test.go b/backend/internal/handler/widget/widget_handler_test.go index ed0277e2..4e51b344 100644 --- a/backend/internal/handler/widget/widget_handler_test.go +++ b/backend/internal/handler/widget/widget_handler_test.go @@ -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) { diff --git a/backend/internal/model/widget_offline_message.go b/backend/internal/model/widget_offline_message.go index d0a917a7..4a3be863 100644 --- a/backend/internal/model/widget_offline_message.go +++ b/backend/internal/model/widget_offline_message.go @@ -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 -} \ No newline at end of file + 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 +} diff --git a/backend/internal/service/widget_offline_message_test.go b/backend/internal/service/widget_offline_message_test.go index 3beb3955..4036ed3d 100644 --- a/backend/internal/service/widget_offline_message_test.go +++ b/backend/internal/service/widget_offline_message_test.go @@ -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) diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index e663258a..99896503 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -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 diff --git a/backend/internal/service/widget_visitor.go b/backend/internal/service/widget_visitor.go new file mode 100644 index 00000000..12047cb9 --- /dev/null +++ b/backend/internal/service/widget_visitor.go @@ -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)) +} diff --git a/backend/internal/service/widget_visitor_test.go b/backend/internal/service/widget_visitor_test.go new file mode 100644 index 00000000..2a2bc1f9 --- /dev/null +++ b/backend/internal/service/widget_visitor_test.go @@ -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) +} diff --git a/deploy/docker/docker-compose.dev.yml b/deploy/docker/docker-compose.dev.yml index 7753e30f..3461cf6e 100644 --- a/deploy/docker/docker-compose.dev.yml +++ b/deploy/docker/docker-compose.dev.yml @@ -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: diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index c00c7d17..38a65f1a 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -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 diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 6038ff34..a5434f29 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -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"] diff --git a/deploy/quickstart/.env.example b/deploy/quickstart/.env.example index 7e5edb69..3c0a2287 100644 --- a/deploy/quickstart/.env.example +++ b/deploy/quickstart/.env.example @@ -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 diff --git a/deploy/quickstart/compose.yaml b/deploy/quickstart/compose.yaml index e8aaa710..136025a6 100644 --- a/deploy/quickstart/compose.yaml +++ b/deploy/quickstart/compose.yaml @@ -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 diff --git a/docs/ops/01-rolling-upgrade.md b/docs/ops/01-rolling-upgrade.md index 0e6110e5..fb0af43b 100644 --- a/docs/ops/01-rolling-upgrade.md +++ b/docs/ops/01-rolling-upgrade.md @@ -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:' +export GOCHAT_IMAGE_REF='git.ipao.vip/rogee/gochat@sha256:' 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' diff --git a/docs/plans/2026-09-14-anonymous-visitor-ip-geolocation-plan.md b/docs/plans/2026-09-14-anonymous-visitor-ip-geolocation-plan.md new file mode 100644 index 00000000..a5785c0c --- /dev/null +++ b/docs/plans/2026-09-14-anonymous-visitor-ip-geolocation-plan.md @@ -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. 文件与代码变更计划 + +### 阶段 A:GeoIP 基础能力 + +预计涉及: + +- `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 可以独立测试,数据库不可用不会导致应用启动失败。 + +### 阶段 B:Widget 服务端接入 + +预计涉及: + +- `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 属性不足时,才单独增加这些能力。 diff --git a/docs/runbooks/anonymous-visitor-geoip.md b/docs/runbooks/anonymous-visitor-geoip.md new file mode 100644 index 00000000..9c2c2858 --- /dev/null +++ b/docs/runbooks/anonymous-visitor-geoip.md @@ -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`。