feat: geolocate anonymous widget visitors
Build and publish Docker images / Build and publish images (push) Successful in 2m10s
Build and publish Docker images / Build and publish images (push) Successful in 2m10s
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. 文件与代码变更计划
|
||||
|
||||
### 阶段 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 属性不足时,才单独增加这些能力。
|
||||
@@ -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`。
|
||||
Reference in New Issue
Block a user