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:
368
backend/internal/travel/amadeus.go
Normal file
368
backend/internal/travel/amadeus.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AmadeusClient struct {
|
||||
apiKey string
|
||||
apiSecret string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.RWMutex
|
||||
}
|
||||
|
||||
type AmadeusConfig struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewAmadeusClient(cfg AmadeusConfig) *AmadeusClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://test.api.amadeus.com"
|
||||
}
|
||||
|
||||
return &AmadeusClient{
|
||||
apiKey: cfg.APIKey,
|
||||
apiSecret: cfg.APISecret,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) getAccessToken(ctx context.Context) (string, error) {
|
||||
c.tokenMu.RLock()
|
||||
if c.accessToken != "" && time.Now().Before(c.tokenExpiry) {
|
||||
token := c.accessToken
|
||||
c.tokenMu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
c.tokenMu.RUnlock()
|
||||
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
|
||||
if c.accessToken != "" && time.Now().Before(c.tokenExpiry) {
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "client_credentials")
|
||||
data.Set("client_id", c.apiKey)
|
||||
data.Set("client_secret", c.apiSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/security/oauth2/token", bytes.NewBufferString(data.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("token request failed: %s - %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
|
||||
c.accessToken = tokenResp.AccessToken
|
||||
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second)
|
||||
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) doRequest(ctx context.Context, method, path string, query url.Values, body interface{}) ([]byte, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fullURL := c.baseURL + path
|
||||
if len(query) > 0 {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("originLocationCode", req.Origin)
|
||||
query.Set("destinationLocationCode", req.Destination)
|
||||
query.Set("departureDate", req.DepartureDate)
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
|
||||
if req.ReturnDate != "" {
|
||||
query.Set("returnDate", req.ReturnDate)
|
||||
}
|
||||
if req.Children > 0 {
|
||||
query.Set("children", fmt.Sprintf("%d", req.Children))
|
||||
}
|
||||
if req.CabinClass != "" {
|
||||
query.Set("travelClass", req.CabinClass)
|
||||
}
|
||||
if req.MaxPrice > 0 {
|
||||
query.Set("maxPrice", fmt.Sprintf("%d", req.MaxPrice))
|
||||
}
|
||||
if req.Currency != "" {
|
||||
query.Set("currencyCode", req.Currency)
|
||||
}
|
||||
query.Set("max", "10")
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v2/shopping/flight-offers", query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []amadeusFlightOffer `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
offer := c.convertFlightOffer(data)
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("cityCode", req.CityCode)
|
||||
query.Set("checkInDate", req.CheckIn)
|
||||
query.Set("checkOutDate", req.CheckOut)
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
|
||||
if req.Rooms > 0 {
|
||||
query.Set("roomQuantity", fmt.Sprintf("%d", req.Rooms))
|
||||
}
|
||||
if req.Currency != "" {
|
||||
query.Set("currency", req.Currency)
|
||||
}
|
||||
if req.Rating > 0 {
|
||||
query.Set("ratings", fmt.Sprintf("%d", req.Rating))
|
||||
}
|
||||
query.Set("bestRateOnly", "true")
|
||||
|
||||
hotelListBody, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations/hotels/by-city", query, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch hotel list: %w", err)
|
||||
}
|
||||
|
||||
var hotelListResp struct {
|
||||
Data []struct {
|
||||
HotelID string `json:"hotelId"`
|
||||
Name string `json:"name"`
|
||||
GeoCode struct {
|
||||
Lat float64 `json:"latitude"`
|
||||
Lng float64 `json:"longitude"`
|
||||
} `json:"geoCode"`
|
||||
Address struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"address"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(hotelListBody, &hotelListResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotel list: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]HotelOffer, 0)
|
||||
for i, h := range hotelListResp.Data {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
offer := HotelOffer{
|
||||
ID: h.HotelID,
|
||||
Name: h.Name,
|
||||
Lat: h.GeoCode.Lat,
|
||||
Lng: h.GeoCode.Lng,
|
||||
Currency: req.Currency,
|
||||
CheckIn: req.CheckIn,
|
||||
CheckOut: req.CheckOut,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) GetAirportByCode(ctx context.Context, code string) (*GeoLocation, error) {
|
||||
query := url.Values{}
|
||||
query.Set("subType", "AIRPORT")
|
||||
query.Set("keyword", code)
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations", query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
Name string `json:"name"`
|
||||
GeoCode struct {
|
||||
Lat float64 `json:"latitude"`
|
||||
Lng float64 `json:"longitude"`
|
||||
} `json:"geoCode"`
|
||||
Address struct {
|
||||
CountryName string `json:"countryName"`
|
||||
} `json:"address"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response.Data) == 0 {
|
||||
return nil, fmt.Errorf("airport not found: %s", code)
|
||||
}
|
||||
|
||||
loc := response.Data[0]
|
||||
return &GeoLocation{
|
||||
Lat: loc.GeoCode.Lat,
|
||||
Lng: loc.GeoCode.Lng,
|
||||
Name: loc.Name,
|
||||
Country: loc.Address.CountryName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type amadeusFlightOffer struct {
|
||||
ID string `json:"id"`
|
||||
Itineraries []struct {
|
||||
Duration string `json:"duration"`
|
||||
Segments []struct {
|
||||
Departure struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
At string `json:"at"`
|
||||
} `json:"departure"`
|
||||
Arrival struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
At string `json:"at"`
|
||||
} `json:"arrival"`
|
||||
CarrierCode string `json:"carrierCode"`
|
||||
Number string `json:"number"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"segments"`
|
||||
} `json:"itineraries"`
|
||||
Price struct {
|
||||
Total string `json:"total"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"price"`
|
||||
TravelerPricings []struct {
|
||||
FareDetailsBySegment []struct {
|
||||
Cabin string `json:"cabin"`
|
||||
} `json:"fareDetailsBySegment"`
|
||||
} `json:"travelerPricings"`
|
||||
NumberOfBookableSeats int `json:"numberOfBookableSeats"`
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) convertFlightOffer(data amadeusFlightOffer) FlightOffer {
|
||||
offer := FlightOffer{
|
||||
ID: data.ID,
|
||||
SeatsAvailable: data.NumberOfBookableSeats,
|
||||
}
|
||||
|
||||
if len(data.Itineraries) > 0 && len(data.Itineraries[0].Segments) > 0 {
|
||||
itin := data.Itineraries[0]
|
||||
firstSeg := itin.Segments[0]
|
||||
lastSeg := itin.Segments[len(itin.Segments)-1]
|
||||
|
||||
offer.DepartureAirport = firstSeg.Departure.IATACode
|
||||
offer.DepartureTime = firstSeg.Departure.At
|
||||
offer.ArrivalAirport = lastSeg.Arrival.IATACode
|
||||
offer.ArrivalTime = lastSeg.Arrival.At
|
||||
offer.Airline = firstSeg.CarrierCode
|
||||
offer.FlightNumber = firstSeg.CarrierCode + firstSeg.Number
|
||||
offer.Stops = len(itin.Segments) - 1
|
||||
offer.Duration = parseDuration(itin.Duration)
|
||||
}
|
||||
|
||||
if price, err := parseFloat(data.Price.Total); err == nil {
|
||||
offer.Price = price
|
||||
}
|
||||
offer.Currency = data.Price.Currency
|
||||
|
||||
if len(data.TravelerPricings) > 0 && len(data.TravelerPricings[0].FareDetailsBySegment) > 0 {
|
||||
offer.CabinClass = data.TravelerPricings[0].FareDetailsBySegment[0].Cabin
|
||||
}
|
||||
|
||||
return offer
|
||||
}
|
||||
|
||||
func parseDuration(d string) int {
|
||||
var hours, minutes int
|
||||
fmt.Sscanf(d, "PT%dH%dM", &hours, &minutes)
|
||||
return hours*60 + minutes
|
||||
}
|
||||
|
||||
func parseFloat(s string) (float64, error) {
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
Reference in New Issue
Block a user