Files
rogee 4d684a71eb
Build and publish Docker images / Build and publish images (push) Successful in 2m10s
feat: geolocate anonymous widget visitors
2026-09-15 08:54:13 +08:00

159 lines
4.4 KiB
Go

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))
}