Files
gooseek/backend/internal/travel/amadeus.go
home 08bd41e75c 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
2026-03-01 21:58:32 +03:00

369 lines
9.1 KiB
Go

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
}