Files
gooseek/backend/internal/agent/travel_flights_collector.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

267 lines
6.8 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
var (
iataCities map[string]string // lowercase city name -> IATA code
iataCitiesMu sync.RWMutex
iataLoaded bool
)
type tpCityEntry struct {
Code string `json:"code"`
Name string `json:"name"`
CountryCode string `json:"country_code"`
Coordinates struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"coordinates"`
}
func loadIATACities(ctx context.Context, token string) error {
iataCitiesMu.Lock()
defer iataCitiesMu.Unlock()
if iataLoaded {
return nil
}
u := fmt.Sprintf("https://api.travelpayouts.com/data/ru/cities.json?token=%s", url.QueryEscape(token))
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch cities.json: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cities.json returned %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read cities.json: %w", err)
}
var cities []tpCityEntry
if err := json.Unmarshal(body, &cities); err != nil {
return fmt.Errorf("failed to parse cities.json: %w", err)
}
iataCities = make(map[string]string, len(cities))
for _, c := range cities {
if c.Name != "" && c.Code != "" {
iataCities[strings.ToLower(c.Name)] = c.Code
}
}
iataLoaded = true
log.Printf("[travel-flights] loaded %d IATA city codes", len(iataCities))
return nil
}
func cityToIATA(city string) string {
iataCitiesMu.RLock()
defer iataCitiesMu.RUnlock()
normalized := strings.ToLower(strings.TrimSpace(city))
if code, ok := iataCities[normalized]; ok {
return code
}
aliases := map[string]string{
"питер": "LED",
"спб": "LED",
"петербург": "LED",
"мск": "MOW",
"нск": "OVB",
"новосиб": "OVB",
"нижний": "GOJ",
"екб": "SVX",
"ростов": "ROV",
"ростов-на-дону": "ROV",
"красноярск": "KJA",
"владивосток": "VVO",
"калининград": "KGD",
"сочи": "AER",
"адлер": "AER",
"симферополь": "SIP",
"крым": "SIP",
}
if code, ok := aliases[normalized]; ok {
return code
}
return ""
}
type tpFlightResponse struct {
Data []tpFlightData `json:"data"`
Success bool `json:"success"`
}
type tpFlightData 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"`
DurationTo int `json:"duration_to"`
DurationBack int `json:"duration_back"`
Link string `json:"link"`
Gate string `json:"gate"`
}
// CollectFlightsFromTP searches TravelPayouts for flights between origin and destinations.
func CollectFlightsFromTP(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) ([]TransportOption, error) {
if cfg.TravelPayoutsToken == "" {
return nil, nil
}
if brief.Origin == "" || len(brief.Destinations) == 0 {
return nil, nil
}
if err := loadIATACities(ctx, cfg.TravelPayoutsToken); err != nil {
log.Printf("[travel-flights] failed to load IATA cities: %v", err)
}
originIATA := cityToIATA(brief.Origin)
if originIATA == "" {
log.Printf("[travel-flights] unknown IATA for origin '%s'", brief.Origin)
return nil, nil
}
var allTransport []TransportOption
passengers := brief.Travelers
if passengers < 1 {
passengers = 1
}
for _, dest := range brief.Destinations {
destIATA := cityToIATA(dest)
if destIATA == "" {
log.Printf("[travel-flights] unknown IATA for destination '%s'", dest)
continue
}
flights, err := searchTPFlights(ctx, cfg.TravelPayoutsToken, cfg.TravelPayoutsMarker, originIATA, destIATA, brief.StartDate, brief.EndDate)
if err != nil {
log.Printf("[travel-flights] search failed %s->%s: %v", originIATA, destIATA, err)
continue
}
for _, f := range flights {
allTransport = append(allTransport, TransportOption{
ID: uuid.New().String(),
Mode: "flight",
From: brief.Origin,
To: dest,
Departure: f.DepartureAt,
Arrival: "",
DurationMin: f.DurationTo,
PricePerUnit: f.Price,
Passengers: passengers,
Price: f.Price * float64(passengers),
Currency: "RUB",
Provider: f.Gate,
BookingURL: buildTPBookingURL(f.Link, cfg.TravelPayoutsMarker),
Airline: f.Airline,
FlightNum: f.FlightNumber,
Stops: f.Transfers,
})
}
}
if len(allTransport) > 10 {
allTransport = allTransport[:10]
}
return allTransport, nil
}
func searchTPFlights(ctx context.Context, token, marker, origin, destination, departDate, returnDate string) ([]tpFlightData, error) {
params := url.Values{
"origin": {origin},
"destination": {destination},
"currency": {"rub"},
"sorting": {"price"},
"limit": {"5"},
"token": {token},
}
if departDate != "" {
params.Set("departure_at", departDate)
}
if returnDate != "" && returnDate != departDate {
params.Set("return_at", returnDate)
}
u := "https://api.travelpayouts.com/aviasales/v3/prices_for_dates?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("TP flights request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("TP flights returned %d: %s", resp.StatusCode, string(body))
}
var result tpFlightResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("TP flights decode error: %w", err)
}
return result.Data, nil
}
func buildTPBookingURL(link, marker string) string {
if link == "" {
return ""
}
base := "https://www.aviasales.ru" + link
if marker != "" {
if strings.Contains(base, "?") {
base += "&marker=" + marker
} else {
base += "?marker=" + marker
}
}
return base
}