feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService) - Add travel orchestrator with parallel collectors (events, POI, hotels, flights) - Add 2GIS road routing with transport cost calculation (car/bus/taxi) - Add TravelMap (2GIS MapGL) and TravelWidgets components - Add useTravelChat hook for streaming travel agent responses - Add finance heatmap providers refactor - Add SearXNG settings, API proxy routes, Docker compose updates - Update Dockerfiles, config, types, and all UI pages for consistency Made-with: Cursor
This commit is contained in:
454
backend/internal/agent/travel_data_client.go
Normal file
454
backend/internal/agent/travel_data_client.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
geocodeCache = make(map[string]*GeoResult)
|
||||
geocodeCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// TravelDataClient calls the travel-svc data-layer endpoints.
|
||||
type TravelDataClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func NewTravelDataClient(baseURL string) *TravelDataClient {
|
||||
return &TravelDataClient{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
maxRetries: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(attempt*500) * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req.Clone(ctx))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("all %d retries failed: %w", c.maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) Geocode(ctx context.Context, query string) (*GeoResult, error) {
|
||||
// Check in-memory cache first
|
||||
geocodeCacheMu.RLock()
|
||||
if cached, ok := geocodeCache[query]; ok {
|
||||
geocodeCacheMu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
geocodeCacheMu.RUnlock()
|
||||
|
||||
u := fmt.Sprintf("%s/api/v1/travel/geocode?query=%s", c.baseURL, url.QueryEscape(query))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.doWithRetry(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geocode request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("geocode returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result GeoResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("geocode decode error: %w", err)
|
||||
}
|
||||
|
||||
geocodeCacheMu.Lock()
|
||||
geocodeCache[query] = &result
|
||||
geocodeCacheMu.Unlock()
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchPOI(ctx context.Context, lat, lng float64, radius int, categories []string) ([]POICard, error) {
|
||||
body := map[string]interface{}{
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": radius,
|
||||
"limit": 20,
|
||||
}
|
||||
if len(categories) > 0 {
|
||||
body["categories"] = categories
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/poi", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poi search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("poi search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawPOIs []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Category string `json:"category"`
|
||||
Address string `json:"address"`
|
||||
Rating float64 `json:"rating"`
|
||||
Distance float64 `json:"distance"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawPOIs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]POICard, 0, len(rawPOIs))
|
||||
for _, p := range rawPOIs {
|
||||
cards = append(cards, POICard{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Category: p.Category,
|
||||
Address: p.Address,
|
||||
Lat: p.Lat,
|
||||
Lng: p.Lng,
|
||||
Rating: p.Rating,
|
||||
})
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchFlights(ctx context.Context, origin, destination, date string, adults int) ([]TransportOption, error) {
|
||||
body := map[string]interface{}{
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"departureDate": date,
|
||||
"adults": adults,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/flights", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("flights search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("flights search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawFlights []struct {
|
||||
ID string `json:"id"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flightNumber"`
|
||||
DepartureAirport string `json:"departureAirport"`
|
||||
DepartureCity string `json:"departureCity"`
|
||||
DepartureTime string `json:"departureTime"`
|
||||
ArrivalAirport string `json:"arrivalAirport"`
|
||||
ArrivalCity string `json:"arrivalCity"`
|
||||
ArrivalTime string `json:"arrivalTime"`
|
||||
Duration int `json:"duration"`
|
||||
Stops int `json:"stops"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
BookingURL string `json:"bookingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawFlights); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
options := make([]TransportOption, 0, len(rawFlights))
|
||||
for _, f := range rawFlights {
|
||||
options = append(options, TransportOption{
|
||||
ID: f.ID,
|
||||
Mode: "flight",
|
||||
From: f.DepartureCity,
|
||||
To: f.ArrivalCity,
|
||||
Departure: f.DepartureTime,
|
||||
Arrival: f.ArrivalTime,
|
||||
DurationMin: f.Duration,
|
||||
Price: f.Price,
|
||||
Currency: f.Currency,
|
||||
Provider: f.Airline,
|
||||
BookingURL: f.BookingURL,
|
||||
Airline: f.Airline,
|
||||
FlightNum: f.FlightNumber,
|
||||
Stops: f.Stops,
|
||||
})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, checkIn, checkOut string, adults int) ([]HotelCard, error) {
|
||||
body := map[string]interface{}{
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": 10,
|
||||
"checkIn": checkIn,
|
||||
"checkOut": checkOut,
|
||||
"adults": adults,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/hotels", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hotels search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hotels search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawHotels []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Rating float64 `json:"rating"`
|
||||
ReviewCount int `json:"reviewCount"`
|
||||
Stars int `json:"stars"`
|
||||
Price float64 `json:"price"`
|
||||
PricePerNight float64 `json:"pricePerNight"`
|
||||
Currency string `json:"currency"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
Amenities []string `json:"amenities"`
|
||||
Photos []string `json:"photos"`
|
||||
BookingURL string `json:"bookingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawHotels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]HotelCard, 0, len(rawHotels))
|
||||
for _, h := range rawHotels {
|
||||
cards = append(cards, HotelCard{
|
||||
ID: h.ID,
|
||||
Name: h.Name,
|
||||
Stars: h.Stars,
|
||||
Rating: h.Rating,
|
||||
ReviewCount: h.ReviewCount,
|
||||
PricePerNight: h.PricePerNight,
|
||||
TotalPrice: h.Price,
|
||||
Currency: h.Currency,
|
||||
Address: h.Address,
|
||||
Lat: h.Lat,
|
||||
Lng: h.Lng,
|
||||
BookingURL: h.BookingURL,
|
||||
Photos: h.Photos,
|
||||
Amenities: h.Amenities,
|
||||
CheckIn: h.CheckIn,
|
||||
CheckOut: h.CheckOut,
|
||||
})
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// PlaceResult represents a place from 2GIS Places API.
|
||||
type PlaceResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) {
|
||||
body := map[string]interface{}{
|
||||
"query": query,
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": radius,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/places", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doWithRetry(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("places search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("places search returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var places []PlaceResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&places); err != nil {
|
||||
return nil, fmt.Errorf("places decode error: %w", err)
|
||||
}
|
||||
return places, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) GetRoute(ctx context.Context, points []MapPoint, transport string) (*RouteDirectionResult, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("need at least 2 points for route")
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "driving"
|
||||
}
|
||||
|
||||
coords := make([]map[string]float64, len(points))
|
||||
for i, p := range points {
|
||||
coords[i] = map[string]float64{"lat": p.Lat, "lng": p.Lng}
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"points": coords,
|
||||
"profile": transport,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/route", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("route request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("route returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result RouteDirectionResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetRouteSegments builds routes between each consecutive pair of points.
|
||||
func (c *TravelDataClient) GetRouteSegments(ctx context.Context, points []MapPoint, transport string) ([]RouteSegmentResult, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
segments := make([]RouteSegmentResult, 0, len(points)-1)
|
||||
for i := 0; i < len(points)-1; i++ {
|
||||
pair := []MapPoint{points[i], points[i+1]}
|
||||
dir, err := c.GetRoute(ctx, pair, transport)
|
||||
if err != nil {
|
||||
segments = append(segments, RouteSegmentResult{
|
||||
FromName: points[i].Label,
|
||||
ToName: points[i+1].Label,
|
||||
})
|
||||
continue
|
||||
}
|
||||
segments = append(segments, RouteSegmentResult{
|
||||
FromName: points[i].Label,
|
||||
ToName: points[i+1].Label,
|
||||
Distance: dir.Distance,
|
||||
Duration: dir.Duration,
|
||||
Geometry: dir.Geometry,
|
||||
})
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
type GeoResult struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
type RouteDirectionResult struct {
|
||||
Geometry RouteGeometryResult `json:"geometry"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Steps []RouteStepResult `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
type RouteGeometryResult struct {
|
||||
Coordinates [][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteStepResult struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteSegmentResult struct {
|
||||
FromName string `json:"from"`
|
||||
ToName string `json:"to"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Geometry RouteGeometryResult `json:"geometry,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user