turash/bugulma/backend/internal/service/geocoding_service.go

298 lines
8.2 KiB
Go

package service
import (
"bugulma/backend/internal/domain"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// GeocodingService provides geocoding capabilities using Google Maps API
type GeocodingService struct {
apiKey string
httpClient *http.Client
}
// GeocodeResponse represents the response from Google Geocoding API
type GeocodeResponse struct {
Results []GeocodeResult `json:"results"`
Status string `json:"status"`
}
// GeocodeResult represents a single geocoding result
type GeocodeResult struct {
AddressComponents []AddressComponent `json:"address_components"`
FormattedAddress string `json:"formatted_address"`
Geometry Geometry `json:"geometry"`
PlaceID string `json:"place_id"`
Types []string `json:"types"`
}
// AddressComponent represents an address component
type AddressComponent struct {
LongName string `json:"long_name"`
ShortName string `json:"short_name"`
Types []string `json:"types"`
}
// Geometry represents the geometry of a geocoding result
type Geometry struct {
Location Location `json:"location"`
LocationType string `json:"location_type"`
Viewport Bounds `json:"viewport"`
Bounds Bounds `json:"bounds"`
}
// Location represents lat/lng coordinates
type Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
// Bounds represents a bounding box
type Bounds struct {
Northeast Location `json:"northeast"`
Southwest Location `json:"southwest"`
}
// GeocodeResult represents the result of geocoding
type GeocodeCoordinates struct {
Latitude float64
Longitude float64
Address string
PlaceID string
}
// NewGeocodingService creates a new geocoding service
func NewGeocodingService(apiKey string) *GeocodingService {
return &GeocodingService{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// GeocodeAddress geocodes an address string to coordinates
func (s *GeocodingService) GeocodeAddress(ctx context.Context, address string) (*GeocodeCoordinates, error) {
if s.apiKey == "" {
return nil, fmt.Errorf("google maps API key not configured")
}
if address == "" {
return nil, fmt.Errorf("address cannot be empty")
}
// Build request URL
baseURL := "https://maps.googleapis.com/maps/api/geocode/json"
params := url.Values{}
params.Add("address", address)
params.Add("key", s.apiKey)
params.Add("language", "ru") // Russian language for results
reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode())
// Create request
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Make request
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("geocoding API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var geocodeResp GeocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&geocodeResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if geocodeResp.Status != "OK" && geocodeResp.Status != "ZERO_RESULTS" {
return nil, fmt.Errorf("geocoding API error: %s", geocodeResp.Status)
}
if len(geocodeResp.Results) == 0 {
return nil, fmt.Errorf("no results found for address: %s", address)
}
// Use first result
result := geocodeResp.Results[0]
return &GeocodeCoordinates{
Latitude: result.Geometry.Location.Lat,
Longitude: result.Geometry.Location.Lng,
Address: result.FormattedAddress,
PlaceID: result.PlaceID,
}, nil
}
// ReverseGeocode performs reverse geocoding (coordinates to address)
func (s *GeocodingService) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeocodeCoordinates, error) {
if s.apiKey == "" {
return nil, fmt.Errorf("google maps API key not configured")
}
// Build request URL
baseURL := "https://maps.googleapis.com/maps/api/geocode/json"
params := url.Values{}
params.Add("latlng", fmt.Sprintf("%f,%f", lat, lng))
params.Add("key", s.apiKey)
params.Add("language", "ru") // Russian language for results
reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode())
// Create request
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Make request
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("reverse geocoding API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var geocodeResp GeocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&geocodeResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if geocodeResp.Status != "OK" && geocodeResp.Status != "ZERO_RESULTS" {
return nil, fmt.Errorf("reverse geocoding API error: %s", geocodeResp.Status)
}
if len(geocodeResp.Results) == 0 {
return nil, fmt.Errorf("no results found for coordinates: %f,%f", lat, lng)
}
// Use first result
result := geocodeResp.Results[0]
return &GeocodeCoordinates{
Latitude: lat,
Longitude: lng,
Address: result.FormattedAddress,
PlaceID: result.PlaceID,
}, nil
}
// EnrichAddressWithCoordinates enriches an address with coordinates if missing
func (s *GeocodingService) EnrichAddressWithCoordinates(ctx context.Context, address *domain.Address) error {
// Skip if already has coordinates
if address.Latitude != 0 && address.Longitude != 0 {
return nil
}
// Build address string from components
addressStr := address.FormattedRu
if addressStr == "" {
addressStr = address.FormattedEn
}
if addressStr == "" {
// Build from components
parts := []string{}
if address.Street != "" {
parts = append(parts, address.Street)
}
if address.City != "" {
parts = append(parts, address.City)
}
if address.Region != "" {
parts = append(parts, address.Region)
}
if len(parts) == 0 {
return fmt.Errorf("address has no usable components for geocoding")
}
addressStr = fmt.Sprintf("%s, Россия", fmt.Sprintf("%s", parts[0]))
if len(parts) > 1 {
addressStr = fmt.Sprintf("%s, %s, Россия", parts[0], parts[1])
}
}
// Geocode
coords, err := s.GeocodeAddress(ctx, addressStr)
if err != nil {
return fmt.Errorf("failed to geocode address: %w", err)
}
// Update address
address.Latitude = coords.Latitude
address.Longitude = coords.Longitude
if address.FormattedRu == "" && coords.Address != "" {
address.FormattedRu = coords.Address
}
return nil
}
// EnrichOrganizationWithCoordinates enriches an organization with coordinates from its addresses
func (s *GeocodingService) EnrichOrganizationWithCoordinates(ctx context.Context, org *domain.Organization) error {
// Skip if already has coordinates
if org.Latitude != 0 && org.Longitude != 0 {
return nil
}
// Try to get coordinates from primary address
if len(org.Addresses) > 0 {
// Find headquarters address first
for i := range org.Addresses {
addr := &org.Addresses[i]
if addr.AddressType == domain.AddressTypeHeadquarters {
if addr.Latitude != 0 && addr.Longitude != 0 {
org.Latitude = addr.Latitude
org.Longitude = addr.Longitude
return nil
}
// Try to geocode it
if err := s.EnrichAddressWithCoordinates(ctx, addr); err == nil {
org.Latitude = addr.Latitude
org.Longitude = addr.Longitude
return nil
}
}
}
// Fallback to first address with coordinates
for i := range org.Addresses {
addr := &org.Addresses[i]
if addr.Latitude != 0 && addr.Longitude != 0 {
org.Latitude = addr.Latitude
org.Longitude = addr.Longitude
return nil
}
}
// Try to geocode first address
if len(org.Addresses) > 0 {
addr := &org.Addresses[0]
if err := s.EnrichAddressWithCoordinates(ctx, addr); err == nil {
org.Latitude = addr.Latitude
org.Longitude = addr.Longitude
return nil
}
}
}
return fmt.Errorf("no addresses available for organization %s", org.ID)
}