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