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:
276
backend/internal/travel/travelpayouts.go
Normal file
276
backend/internal/travel/travelpayouts.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TravelPayoutsClient struct {
|
||||
token string
|
||||
marker string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type TravelPayoutsConfig struct {
|
||||
Token string
|
||||
Marker string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewTravelPayoutsClient(cfg TravelPayoutsConfig) *TravelPayoutsClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.travelpayouts.com"
|
||||
}
|
||||
|
||||
return &TravelPayoutsClient{
|
||||
token: cfg.Token,
|
||||
marker: cfg.Marker,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) doRequest(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
||||
if query == nil {
|
||||
query = url.Values{}
|
||||
}
|
||||
query.Set("token", c.token)
|
||||
if c.marker != "" {
|
||||
query.Set("marker", c.marker)
|
||||
}
|
||||
|
||||
fullURL := c.baseURL + path + "?" + query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, 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(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", req.Origin)
|
||||
query.Set("destination", req.Destination)
|
||||
query.Set("depart_date", req.DepartureDate)
|
||||
if req.ReturnDate != "" {
|
||||
query.Set("return_date", req.ReturnDate)
|
||||
}
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
if req.Currency != "" {
|
||||
query.Set("currency", req.Currency)
|
||||
} else {
|
||||
query.Set("currency", "rub")
|
||||
}
|
||||
query.Set("limit", "10")
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data []struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
OriginAirport string `json:"origin_airport"`
|
||||
DestAirport string `json:"destination_airport"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flight_number"`
|
||||
DepartureAt string `json:"departure_at"`
|
||||
ReturnAt string `json:"return_at"`
|
||||
Transfers int `json:"transfers"`
|
||||
ReturnTransfers int `json:"return_transfers"`
|
||||
Duration int `json:"duration"`
|
||||
Link string `json:"link"`
|
||||
} `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 _, d := range response.Data {
|
||||
offer := FlightOffer{
|
||||
ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartureAt),
|
||||
Airline: d.Airline,
|
||||
FlightNumber: d.FlightNumber,
|
||||
DepartureAirport: d.OriginAirport,
|
||||
DepartureCity: d.Origin,
|
||||
DepartureTime: d.DepartureAt,
|
||||
ArrivalAirport: d.DestAirport,
|
||||
ArrivalCity: d.Destination,
|
||||
ArrivalTime: d.ReturnAt,
|
||||
Duration: d.Duration,
|
||||
Stops: d.Transfers,
|
||||
Price: d.Price,
|
||||
Currency: req.Currency,
|
||||
BookingURL: "https://www.aviasales.ru" + d.Link,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetCheapestPrices(ctx context.Context, origin, destination string, currency string) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", origin)
|
||||
query.Set("destination", destination)
|
||||
if currency != "" {
|
||||
query.Set("currency", currency)
|
||||
} else {
|
||||
query.Set("currency", "rub")
|
||||
}
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data []struct {
|
||||
DepartDate string `json:"depart_date"`
|
||||
ReturnDate string `json:"return_date"`
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
Transfers int `json:"number_of_changes"`
|
||||
Link string `json:"link"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal prices: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, d := range response.Data {
|
||||
offer := FlightOffer{
|
||||
ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartDate),
|
||||
Airline: d.Airline,
|
||||
DepartureCity: d.Origin,
|
||||
DepartureTime: d.DepartDate,
|
||||
ArrivalCity: d.Destination,
|
||||
ArrivalTime: d.ReturnDate,
|
||||
Stops: d.Transfers,
|
||||
Price: d.Price,
|
||||
Currency: currency,
|
||||
BookingURL: "https://www.aviasales.ru" + d.Link,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", origin)
|
||||
query.Set("currency", "rub")
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/city_directions", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
Price float64 `json:"price"`
|
||||
Transfers int `json:"transfers"`
|
||||
Airline string `json:"airline"`
|
||||
DepartDate string `json:"departure_at"`
|
||||
ReturnDate string `json:"return_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal destinations: %w", err)
|
||||
}
|
||||
|
||||
suggestions := make([]TravelSuggestion, 0)
|
||||
for dest, d := range response.Data {
|
||||
suggestion := TravelSuggestion{
|
||||
ID: dest,
|
||||
Type: "destination",
|
||||
Title: dest,
|
||||
Description: fmt.Sprintf("от %s, %d пересадок", d.Airline, d.Transfers),
|
||||
Price: d.Price,
|
||||
Currency: "RUB",
|
||||
}
|
||||
suggestions = append(suggestions, suggestion)
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetAirportByIATA(ctx context.Context, iata string) (*GeoLocation, error) {
|
||||
query := url.Values{}
|
||||
query.Set("code", iata)
|
||||
query.Set("locale", "ru")
|
||||
|
||||
body, err := c.doRequest(ctx, "/data/ru/airports.json", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var airports []struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Coordinates []float64 `json:"coordinates"`
|
||||
Country string `json:"country_code"`
|
||||
City string `json:"city_code"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &airports); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal airports: %w", err)
|
||||
}
|
||||
|
||||
for _, a := range airports {
|
||||
if a.Code == iata && len(a.Coordinates) >= 2 {
|
||||
return &GeoLocation{
|
||||
Lng: a.Coordinates[0],
|
||||
Lat: a.Coordinates[1],
|
||||
Name: a.Name,
|
||||
Country: a.Country,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("airport not found: %s", iata)
|
||||
}
|
||||
Reference in New Issue
Block a user