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:
home
2026-03-01 21:58:32 +03:00
parent e6b9cfc60a
commit 08bd41e75c
71 changed files with 12364 additions and 945 deletions

View 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
}

View File

@@ -0,0 +1,57 @@
package travel
import (
"context"
"github.com/gooseek/backend/internal/llm"
)
type LLMClientAdapter struct {
client llm.Client
}
func NewLLMClientAdapter(client llm.Client) *LLMClientAdapter {
return &LLMClientAdapter{client: client}
}
func (a *LLMClientAdapter) StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error {
llmMessages := make([]llm.Message, len(messages))
for i, m := range messages {
var role llm.Role
switch m.Role {
case "system":
role = llm.RoleSystem
case "user":
role = llm.RoleUser
case "assistant":
role = llm.RoleAssistant
default:
role = llm.RoleUser
}
llmMessages[i] = llm.Message{
Role: role,
Content: m.Content,
}
}
req := llm.StreamRequest{
Messages: llmMessages,
Options: llm.StreamOptions{
MaxTokens: 4096,
Temperature: 0.7,
},
}
ch, err := a.client.StreamText(ctx, req)
if err != nil {
return err
}
for chunk := range ch {
if chunk.ContentChunk != "" {
onChunk(chunk.ContentChunk)
}
}
return nil
}

View File

@@ -0,0 +1,335 @@
package travel
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type OpenRouteClient struct {
apiKey string
baseURL string
httpClient *http.Client
}
func (c *OpenRouteClient) HasAPIKey() bool {
return c.apiKey != ""
}
type OpenRouteConfig struct {
APIKey string
BaseURL string
}
func NewOpenRouteClient(cfg OpenRouteConfig) *OpenRouteClient {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = "https://api.openrouteservice.org"
}
return &OpenRouteClient{
apiKey: cfg.APIKey,
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *OpenRouteClient) doRequest(ctx context.Context, method, path string, query url.Values) ([]byte, error) {
fullURL := c.baseURL + path
if len(query) > 0 {
fullURL += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", c.apiKey)
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 *OpenRouteClient) GetDirections(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) {
if len(points) < 2 {
return nil, fmt.Errorf("at least 2 points required")
}
if profile == "" {
profile = "driving-car"
}
coords := make([]string, len(points))
for i, p := range points {
coords[i] = fmt.Sprintf("%f,%f", p.Lng, p.Lat)
}
query := url.Values{}
query.Set("start", coords[0])
query.Set("end", coords[len(coords)-1])
body, err := c.doRequest(ctx, "GET", "/v2/directions/"+profile, query)
if err != nil {
return nil, err
}
var response struct {
Features []struct {
Geometry struct {
Coordinates [][2]float64 `json:"coordinates"`
Type string `json:"type"`
} `json:"geometry"`
Properties struct {
Summary struct {
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
} `json:"summary"`
Segments []struct {
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Steps []struct {
Instruction string `json:"instruction"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Type int `json:"type"`
} `json:"steps"`
} `json:"segments"`
} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal directions: %w", err)
}
if len(response.Features) == 0 {
return nil, fmt.Errorf("no route found")
}
feature := response.Features[0]
direction := &RouteDirection{
Geometry: RouteGeometry{
Coordinates: feature.Geometry.Coordinates,
Type: feature.Geometry.Type,
},
Distance: feature.Properties.Summary.Distance,
Duration: feature.Properties.Summary.Duration,
}
for _, seg := range feature.Properties.Segments {
for _, step := range seg.Steps {
direction.Steps = append(direction.Steps, RouteStep{
Instruction: step.Instruction,
Distance: step.Distance,
Duration: step.Duration,
Type: fmt.Sprintf("%d", step.Type),
})
}
}
return direction, nil
}
func (c *OpenRouteClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
params := url.Values{}
params.Set("api_key", c.apiKey)
params.Set("text", query)
params.Set("size", "1")
body, err := c.doRequest(ctx, "GET", "/geocode/search", params)
if err != nil {
return nil, err
}
var response struct {
Features []struct {
Geometry struct {
Coordinates [2]float64 `json:"coordinates"`
} `json:"geometry"`
Properties struct {
Name string `json:"name"`
Country string `json:"country"`
Label string `json:"label"`
} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal geocode: %w", err)
}
if len(response.Features) == 0 {
return nil, fmt.Errorf("location not found: %s", query)
}
feature := response.Features[0]
return &GeoLocation{
Lng: feature.Geometry.Coordinates[0],
Lat: feature.Geometry.Coordinates[1],
Name: feature.Properties.Name,
Country: feature.Properties.Country,
}, nil
}
func (c *OpenRouteClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) {
params := url.Values{}
params.Set("api_key", c.apiKey)
params.Set("point.lat", fmt.Sprintf("%f", lat))
params.Set("point.lon", fmt.Sprintf("%f", lng))
params.Set("size", "1")
body, err := c.doRequest(ctx, "GET", "/geocode/reverse", params)
if err != nil {
return nil, err
}
var response struct {
Features []struct {
Properties struct {
Name string `json:"name"`
Country string `json:"country"`
Label string `json:"label"`
} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal reverse geocode: %w", err)
}
if len(response.Features) == 0 {
return nil, fmt.Errorf("location not found at %f,%f", lat, lng)
}
feature := response.Features[0]
return &GeoLocation{
Lat: lat,
Lng: lng,
Name: feature.Properties.Name,
Country: feature.Properties.Country,
}, nil
}
func (c *OpenRouteClient) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) {
params := url.Values{}
params.Set("api_key", c.apiKey)
params.Set("request", "pois")
params.Set("geometry", fmt.Sprintf(`{"geojson":{"type":"Point","coordinates":[%f,%f]},"buffer":%d}`, req.Lng, req.Lat, req.Radius))
if len(req.Categories) > 0 {
params.Set("filters", fmt.Sprintf(`{"category_ids":[%s]}`, strings.Join(req.Categories, ",")))
}
limit := req.Limit
if limit == 0 {
limit = 20
}
params.Set("limit", fmt.Sprintf("%d", limit))
body, err := c.doRequest(ctx, "POST", "/pois", params)
if err != nil {
return nil, err
}
var response struct {
Features []struct {
Geometry struct {
Coordinates [2]float64 `json:"coordinates"`
} `json:"geometry"`
Properties struct {
OSMId int64 `json:"osm_id"`
Name string `json:"osm_tags.name"`
Category struct {
ID int `json:"category_id"`
Name string `json:"category_name"`
} `json:"category"`
Distance float64 `json:"distance"`
} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal POI: %w", err)
}
pois := make([]POI, 0, len(response.Features))
for _, f := range response.Features {
poi := POI{
ID: fmt.Sprintf("%d", f.Properties.OSMId),
Name: f.Properties.Name,
Lng: f.Geometry.Coordinates[0],
Lat: f.Geometry.Coordinates[1],
Category: f.Properties.Category.Name,
Distance: f.Properties.Distance,
}
pois = append(pois, poi)
}
return pois, nil
}
func (c *OpenRouteClient) GetIsochrone(ctx context.Context, lat, lng float64, timeMinutes int, profile string) (*RouteGeometry, error) {
if profile == "" {
profile = "driving-car"
}
params := url.Values{}
params.Set("api_key", c.apiKey)
params.Set("locations", fmt.Sprintf("%f,%f", lng, lat))
params.Set("range", fmt.Sprintf("%d", timeMinutes*60))
body, err := c.doRequest(ctx, "GET", "/v2/isochrones/"+profile, params)
if err != nil {
return nil, err
}
var response struct {
Features []struct {
Geometry struct {
Coordinates [][][2]float64 `json:"coordinates"`
Type string `json:"type"`
} `json:"geometry"`
} `json:"features"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal isochrone: %w", err)
}
if len(response.Features) == 0 {
return nil, fmt.Errorf("no isochrone found")
}
coords := make([][2]float64, 0)
if len(response.Features[0].Geometry.Coordinates) > 0 {
coords = response.Features[0].Geometry.Coordinates[0]
}
return &RouteGeometry{
Coordinates: coords,
Type: response.Features[0].Geometry.Type,
}, nil
}

View File

@@ -0,0 +1,501 @@
package travel
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) InitSchema(ctx context.Context) error {
query := `
CREATE TABLE IF NOT EXISTS trips (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
title VARCHAR(255) NOT NULL,
destination VARCHAR(255) NOT NULL,
description TEXT,
cover_image TEXT,
start_date TIMESTAMP NOT NULL,
end_date TIMESTAMP NOT NULL,
route JSONB DEFAULT '[]',
flights JSONB DEFAULT '[]',
hotels JSONB DEFAULT '[]',
total_budget DECIMAL(12,2),
currency VARCHAR(3) DEFAULT 'RUB',
status VARCHAR(20) DEFAULT 'planned',
ai_summary TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id);
CREATE INDEX IF NOT EXISTS idx_trips_status ON trips(status);
CREATE INDEX IF NOT EXISTS idx_trips_start_date ON trips(start_date);
CREATE TABLE IF NOT EXISTS trip_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
session_id VARCHAR(255),
brief JSONB DEFAULT '{}',
candidates JSONB DEFAULT '{}',
selected JSONB DEFAULT '{}',
phase VARCHAR(50) DEFAULT 'planning',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trip_drafts_user_id ON trip_drafts(user_id);
CREATE INDEX IF NOT EXISTS idx_trip_drafts_session_id ON trip_drafts(session_id);
CREATE TABLE IF NOT EXISTS geocode_cache (
query_hash VARCHAR(64) PRIMARY KEY,
query_text TEXT NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
name TEXT,
country TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at);
`
_, err := r.db.ExecContext(ctx, query)
return err
}
func (r *Repository) CreateTrip(ctx context.Context, trip *Trip) error {
if trip.ID == "" {
trip.ID = uuid.New().String()
}
trip.CreatedAt = time.Now()
trip.UpdatedAt = time.Now()
routeJSON, err := json.Marshal(trip.Route)
if err != nil {
return fmt.Errorf("marshal route: %w", err)
}
flightsJSON, err := json.Marshal(trip.Flights)
if err != nil {
return fmt.Errorf("marshal flights: %w", err)
}
hotelsJSON, err := json.Marshal(trip.Hotels)
if err != nil {
return fmt.Errorf("marshal hotels: %w", err)
}
query := `
INSERT INTO trips (
id, user_id, title, destination, description, cover_image,
start_date, end_date, route, flights, hotels,
total_budget, currency, status, ai_summary, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`
_, err = r.db.ExecContext(ctx, query,
trip.ID, trip.UserID, trip.Title, trip.Destination, trip.Description, trip.CoverImage,
trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON,
trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.CreatedAt, trip.UpdatedAt,
)
return err
}
func (r *Repository) GetTrip(ctx context.Context, id string) (*Trip, error) {
query := `
SELECT id, user_id, title, destination, description, cover_image,
start_date, end_date, route, flights, hotels,
total_budget, currency, status, ai_summary, created_at, updated_at
FROM trips WHERE id = $1
`
var trip Trip
var routeJSON, flightsJSON, hotelsJSON []byte
var description, coverImage, aiSummary sql.NullString
var totalBudget sql.NullFloat64
err := r.db.QueryRowContext(ctx, query, id).Scan(
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if description.Valid {
trip.Description = description.String
}
if coverImage.Valid {
trip.CoverImage = coverImage.String
}
if aiSummary.Valid {
trip.AISummary = aiSummary.String
}
if totalBudget.Valid {
trip.TotalBudget = totalBudget.Float64
}
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
return nil, fmt.Errorf("unmarshal route: %w", err)
}
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
return nil, fmt.Errorf("unmarshal flights: %w", err)
}
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
return nil, fmt.Errorf("unmarshal hotels: %w", err)
}
return &trip, nil
}
func (r *Repository) GetTripsByUser(ctx context.Context, userID string, limit, offset int) ([]Trip, error) {
if limit == 0 {
limit = 20
}
query := `
SELECT id, user_id, title, destination, description, cover_image,
start_date, end_date, route, flights, hotels,
total_budget, currency, status, ai_summary, created_at, updated_at
FROM trips WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var trips []Trip
for rows.Next() {
var trip Trip
var routeJSON, flightsJSON, hotelsJSON []byte
var description, coverImage, aiSummary sql.NullString
var totalBudget sql.NullFloat64
err := rows.Scan(
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
)
if err != nil {
return nil, err
}
if description.Valid {
trip.Description = description.String
}
if coverImage.Valid {
trip.CoverImage = coverImage.String
}
if aiSummary.Valid {
trip.AISummary = aiSummary.String
}
if totalBudget.Valid {
trip.TotalBudget = totalBudget.Float64
}
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
return nil, fmt.Errorf("unmarshal route: %w", err)
}
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
return nil, fmt.Errorf("unmarshal flights: %w", err)
}
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
return nil, fmt.Errorf("unmarshal hotels: %w", err)
}
trips = append(trips, trip)
}
return trips, nil
}
func (r *Repository) UpdateTrip(ctx context.Context, trip *Trip) error {
trip.UpdatedAt = time.Now()
routeJSON, err := json.Marshal(trip.Route)
if err != nil {
return fmt.Errorf("marshal route: %w", err)
}
flightsJSON, err := json.Marshal(trip.Flights)
if err != nil {
return fmt.Errorf("marshal flights: %w", err)
}
hotelsJSON, err := json.Marshal(trip.Hotels)
if err != nil {
return fmt.Errorf("marshal hotels: %w", err)
}
query := `
UPDATE trips SET
title = $2, destination = $3, description = $4, cover_image = $5,
start_date = $6, end_date = $7, route = $8, flights = $9, hotels = $10,
total_budget = $11, currency = $12, status = $13, ai_summary = $14, updated_at = $15
WHERE id = $1
`
_, err = r.db.ExecContext(ctx, query,
trip.ID, trip.Title, trip.Destination, trip.Description, trip.CoverImage,
trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON,
trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.UpdatedAt,
)
return err
}
func (r *Repository) DeleteTrip(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM trips WHERE id = $1", id)
return err
}
func (r *Repository) GetTripsByStatus(ctx context.Context, userID string, status TripStatus) ([]Trip, error) {
query := `
SELECT id, user_id, title, destination, description, cover_image,
start_date, end_date, route, flights, hotels,
total_budget, currency, status, ai_summary, created_at, updated_at
FROM trips WHERE user_id = $1 AND status = $2
ORDER BY start_date ASC
`
rows, err := r.db.QueryContext(ctx, query, userID, status)
if err != nil {
return nil, err
}
defer rows.Close()
var trips []Trip
for rows.Next() {
var trip Trip
var routeJSON, flightsJSON, hotelsJSON []byte
var description, coverImage, aiSummary sql.NullString
var totalBudget sql.NullFloat64
err := rows.Scan(
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
)
if err != nil {
return nil, err
}
if description.Valid {
trip.Description = description.String
}
if coverImage.Valid {
trip.CoverImage = coverImage.String
}
if aiSummary.Valid {
trip.AISummary = aiSummary.String
}
if totalBudget.Valid {
trip.TotalBudget = totalBudget.Float64
}
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
return nil, fmt.Errorf("unmarshal route: %w", err)
}
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
return nil, fmt.Errorf("unmarshal flights: %w", err)
}
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
return nil, fmt.Errorf("unmarshal hotels: %w", err)
}
trips = append(trips, trip)
}
return trips, nil
}
func (r *Repository) CountTripsByUser(ctx context.Context, userID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM trips WHERE user_id = $1", userID).Scan(&count)
return count, err
}
// --- Trip Draft persistence ---
type TripDraft struct {
ID string `json:"id"`
UserID string `json:"userId"`
SessionID string `json:"sessionId"`
Brief json.RawMessage `json:"brief"`
Candidates json.RawMessage `json:"candidates"`
Selected json.RawMessage `json:"selected"`
Phase string `json:"phase"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (r *Repository) SaveDraft(ctx context.Context, draft *TripDraft) error {
if draft.ID == "" {
draft.ID = uuid.New().String()
}
draft.UpdatedAt = time.Now()
query := `
INSERT INTO trip_drafts (id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
brief = EXCLUDED.brief,
candidates = EXCLUDED.candidates,
selected = EXCLUDED.selected,
phase = EXCLUDED.phase,
updated_at = EXCLUDED.updated_at
`
_, err := r.db.ExecContext(ctx, query,
draft.ID, draft.UserID, draft.SessionID,
draft.Brief, draft.Candidates, draft.Selected,
draft.Phase, draft.CreatedAt, draft.UpdatedAt,
)
return err
}
func (r *Repository) GetDraft(ctx context.Context, id string) (*TripDraft, error) {
query := `
SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at
FROM trip_drafts WHERE id = $1
`
var draft TripDraft
var userID sql.NullString
err := r.db.QueryRowContext(ctx, query, id).Scan(
&draft.ID, &userID, &draft.SessionID,
&draft.Brief, &draft.Candidates, &draft.Selected,
&draft.Phase, &draft.CreatedAt, &draft.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if userID.Valid {
draft.UserID = userID.String
}
return &draft, nil
}
func (r *Repository) GetDraftBySession(ctx context.Context, sessionID string) (*TripDraft, error) {
query := `
SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at
FROM trip_drafts WHERE session_id = $1
ORDER BY updated_at DESC LIMIT 1
`
var draft TripDraft
var userID sql.NullString
err := r.db.QueryRowContext(ctx, query, sessionID).Scan(
&draft.ID, &userID, &draft.SessionID,
&draft.Brief, &draft.Candidates, &draft.Selected,
&draft.Phase, &draft.CreatedAt, &draft.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if userID.Valid {
draft.UserID = userID.String
}
return &draft, nil
}
func (r *Repository) DeleteDraft(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE id = $1", id)
return err
}
func (r *Repository) CleanupOldDrafts(ctx context.Context, olderThan time.Duration) error {
cutoff := time.Now().Add(-olderThan)
_, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE updated_at < $1", cutoff)
return err
}
// --- Geocode cache ---
type GeocodeCacheEntry struct {
QueryHash string `json:"queryHash"`
QueryText string `json:"queryText"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Name string `json:"name"`
Country string `json:"country"`
}
func (r *Repository) GetCachedGeocode(ctx context.Context, queryHash string) (*GeocodeCacheEntry, error) {
query := `
SELECT query_hash, query_text, lat, lng, name, country
FROM geocode_cache WHERE query_hash = $1
`
var entry GeocodeCacheEntry
var name, country sql.NullString
err := r.db.QueryRowContext(ctx, query, queryHash).Scan(
&entry.QueryHash, &entry.QueryText, &entry.Lat, &entry.Lng, &name, &country,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name.Valid {
entry.Name = name.String
}
if country.Valid {
entry.Country = country.String
}
return &entry, nil
}
func (r *Repository) SaveGeocodeCache(ctx context.Context, entry *GeocodeCacheEntry) error {
query := `
INSERT INTO geocode_cache (query_hash, query_text, lat, lng, name, country, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (query_hash) DO UPDATE SET
lat = EXCLUDED.lat, lng = EXCLUDED.lng,
name = EXCLUDED.name, country = EXCLUDED.country
`
_, err := r.db.ExecContext(ctx, query,
entry.QueryHash, entry.QueryText, entry.Lat, entry.Lng, entry.Name, entry.Country,
)
return err
}
func (r *Repository) CleanupOldGeocodeCache(ctx context.Context, olderThan time.Duration) error {
cutoff := time.Now().Add(-olderThan)
_, err := r.db.ExecContext(ctx, "DELETE FROM geocode_cache WHERE created_at < $1", cutoff)
return err
}

View File

@@ -0,0 +1,660 @@
package travel
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
type Service struct {
repo *Repository
amadeus *AmadeusClient
openRoute *OpenRouteClient
travelPayouts *TravelPayoutsClient
twoGIS *TwoGISClient
llmClient LLMClient
useRussianAPIs bool
}
type LLMClient interface {
StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ServiceConfig struct {
Repository *Repository
AmadeusConfig AmadeusConfig
OpenRouteConfig OpenRouteConfig
TravelPayoutsConfig TravelPayoutsConfig
TwoGISConfig TwoGISConfig
LLMClient LLMClient
UseRussianAPIs bool
}
func NewService(cfg ServiceConfig) *Service {
return &Service{
repo: cfg.Repository,
amadeus: NewAmadeusClient(cfg.AmadeusConfig),
openRoute: NewOpenRouteClient(cfg.OpenRouteConfig),
travelPayouts: NewTravelPayoutsClient(cfg.TravelPayoutsConfig),
twoGIS: NewTwoGISClient(cfg.TwoGISConfig),
llmClient: cfg.LLMClient,
useRussianAPIs: cfg.UseRussianAPIs,
}
}
func (s *Service) PlanTrip(ctx context.Context, req TravelPlanRequest, writer io.Writer) error {
systemPrompt := `Ты - AI планировщик путешествий GooSeek.
ГЛАВНОЕ ПРАВИЛО: Если пользователь упоминает ЛЮБОЕ место, город, страну, регион или маршрут - ты ОБЯЗАН сразу построить маршрут с JSON. НЕ задавай уточняющих вопросов, НЕ здоровайся - сразу давай маршрут!
Примеры запросов, на которые нужно СРАЗУ давать маршрут:
- "золотое кольцо" → маршрут по городам Золотого кольца
- "путешествие по Италии" → маршрут по Италии
- "хочу в Париж" → маршрут по Парижу
- "куда поехать в Крыму" → маршрут по Крыму
Отвечай на русском языке. В ответе:
1. Кратко опиши маршрут (2-3 предложения)
2. Перечисли точки с описанием каждой
3. Укажи примерный бюджет
4. В КОНЦЕ ОБЯЗАТЕЛЬНО добавь JSON блок
КООРДИНАТЫ ГОРОДОВ ЗОЛОТОГО КОЛЬЦА:
- Сергиев Посад: lat 56.3100, lng 38.1326
- Переславль-Залесский: lat 56.7389, lng 38.8533
- Ростов Великий: lat 57.1848, lng 39.4142
- Ярославль: lat 57.6261, lng 39.8845
- Кострома: lat 57.7679, lng 40.9269
- Иваново: lat 56.9994, lng 40.9728
- Суздаль: lat 56.4212, lng 40.4496
- Владимир: lat 56.1366, lng 40.3966
ДРУГИЕ ПОПУЛЯРНЫЕ МЕСТА:
- Москва, Красная площадь: lat 55.7539, lng 37.6208
- Санкт-Петербург, Эрмитаж: lat 59.9398, lng 30.3146
- Казань, Кремль: lat 55.7982, lng 49.1064
- Сочи, центр: lat 43.5855, lng 39.7231
- Калининград: lat 54.7104, lng 20.4522
ФОРМАТ JSON (ОБЯЗАТЕЛЕН В КОНЦЕ КАЖДОГО ОТВЕТА):
` + "```json" + `
{
"route": [
{
"name": "Название места",
"lat": 55.7539,
"lng": 37.6208,
"type": "attraction",
"aiComment": "Комментарий о месте",
"duration": 120,
"order": 1
}
],
"suggestions": [
{
"type": "activity",
"title": "Название",
"description": "Описание",
"lat": 55.7539,
"lng": 37.6208
}
]
}
` + "```" + `
ТИПЫ ТОЧЕК: airport, hotel, attraction, restaurant, transport, custom
ТИПЫ SUGGESTIONS: destination, activity, restaurant, transport
ПРАВИЛА:
1. lat и lng - ЧИСЛА (не строки!)
2. duration - минуты (число)
3. order - порядковый номер с 1
4. Минимум 5 точек для маршрута
5. JSON ОБЯЗАТЕЛЕН даже для простых вопросов!
6. НИКОГДА не отвечай без JSON блока!`
messages := []ChatMessage{
{Role: "system", Content: systemPrompt},
}
for _, h := range req.History {
messages = append(messages, ChatMessage{Role: "user", Content: h[0]})
messages = append(messages, ChatMessage{Role: "assistant", Content: h[1]})
}
userMsg := req.Query
if req.StartDate != "" && req.EndDate != "" {
userMsg += fmt.Sprintf("\n\nДаты: %s - %s", req.StartDate, req.EndDate)
}
if req.Travelers > 0 {
userMsg += fmt.Sprintf("\nКоличество путешественников: %d", req.Travelers)
}
if req.Budget > 0 {
currency := req.Currency
if currency == "" {
currency = "RUB"
}
userMsg += fmt.Sprintf("\nБюджет: %.0f %s", req.Budget, currency)
}
if req.Preferences != nil {
if req.Preferences.TravelStyle != "" {
userMsg += fmt.Sprintf("\nСтиль путешествия: %s", req.Preferences.TravelStyle)
}
if len(req.Preferences.Interests) > 0 {
userMsg += fmt.Sprintf("\nИнтересы: %s", strings.Join(req.Preferences.Interests, ", "))
}
}
messages = append(messages, ChatMessage{Role: "user", Content: userMsg})
writeEvent := func(eventType string, data interface{}) {
event := map[string]interface{}{
"type": eventType,
"data": data,
}
jsonData, _ := json.Marshal(event)
writer.Write(jsonData)
writer.Write([]byte("\n"))
if bw, ok := writer.(*bufio.Writer); ok {
bw.Flush()
}
}
writeEvent("messageStart", nil)
var fullResponse strings.Builder
err := s.llmClient.StreamChat(ctx, messages, func(chunk string) {
fullResponse.WriteString(chunk)
writeEvent("textChunk", map[string]string{"chunk": chunk})
})
if err != nil {
writeEvent("error", map[string]string{"message": err.Error()})
return err
}
responseText := fullResponse.String()
fmt.Printf("[travel] Full LLM response length: %d chars\n", len(responseText))
routeData := s.extractRouteFromResponse(ctx, responseText)
if routeData == nil || len(routeData.Route) == 0 {
fmt.Println("[travel] No valid route in response, requesting route generation from LLM...")
routeData = s.requestRouteGeneration(ctx, userMsg, responseText)
}
if routeData != nil && len(routeData.Route) > 0 {
routeData = s.geocodeMissingCoordinates(ctx, routeData)
}
if routeData != nil && len(routeData.Route) > 0 {
for i, p := range routeData.Route {
fmt.Printf("[travel] Point %d: %s (lat=%.6f, lng=%.6f)\n", i+1, p.Name, p.Lat, p.Lng)
}
writeEvent("route", routeData)
fmt.Printf("[travel] Sent route event with %d points\n", len(routeData.Route))
} else {
fmt.Println("[travel] No route data after all attempts")
}
writeEvent("messageEnd", nil)
return nil
}
func (s *Service) requestRouteGeneration(ctx context.Context, userQuery string, originalResponse string) *RouteData {
if s.llmClient == nil {
return nil
}
genPrompt := `Пользователь запросил: "` + userQuery + `"
Ты должен СРАЗУ создать маршрут путешествия. ВЕРНИ ТОЛЬКО JSON без пояснений:
{
"route": [
{"name": "Место 1", "lat": 56.31, "lng": 38.13, "type": "attraction", "aiComment": "Описание", "duration": 120, "order": 1},
{"name": "Место 2", "lat": 56.74, "lng": 38.85, "type": "attraction", "aiComment": "Описание", "duration": 90, "order": 2}
],
"suggestions": []
}
КООРДИНАТЫ ПОПУЛЯРНЫХ МЕСТ:
Золотое кольцо: Сергиев Посад (56.31, 38.13), Переславль-Залесский (56.74, 38.85), Ростов Великий (57.18, 39.41), Ярославль (57.63, 39.88), Кострома (57.77, 40.93), Суздаль (56.42, 40.45), Владимир (56.14, 40.40)
Москва: Красная площадь (55.75, 37.62), Арбат (55.75, 37.59), ВДНХ (55.83, 37.64)
Питер: Эрмитаж (59.94, 30.31), Петергоф (59.88, 29.91), Невский (59.93, 30.35)
Крым: Ялта (44.49, 34.17), Севастополь (44.62, 33.52), Бахчисарай (44.75, 33.86)
ВАЖНО:
- lat и lng - ЧИСЛА
- Минимум 5 точек
- type: airport, hotel, attraction, restaurant, transport, custom
- ТОЛЬКО JSON, без текста до и после!`
messages := []ChatMessage{
{Role: "user", Content: genPrompt},
}
var genResponse strings.Builder
err := s.llmClient.StreamChat(ctx, messages, func(chunk string) {
genResponse.WriteString(chunk)
})
if err != nil {
fmt.Printf("[travel] LLM route generation failed: %v\n", err)
return nil
}
result := genResponse.String()
fmt.Printf("[travel] LLM generated route: %s\n", result)
return s.extractRouteFromResponse(ctx, result)
}
func (s *Service) geocodeMissingCoordinates(ctx context.Context, data *RouteData) *RouteData {
if data == nil {
return nil
}
validPoints := make([]RoutePoint, 0, len(data.Route))
for _, point := range data.Route {
if (point.Lat == 0 && point.Lng == 0) && point.Name != "" {
loc, err := s.Geocode(ctx, point.Name)
if err == nil && loc != nil {
point.Lat = loc.Lat
point.Lng = loc.Lng
if point.Address == "" {
point.Address = loc.Name
}
fmt.Printf("[travel] Geocoded '%s' -> lat=%.4f, lng=%.4f\n", point.Name, point.Lat, point.Lng)
} else {
fmt.Printf("[travel] Failed to geocode '%s': %v\n", point.Name, err)
}
}
if point.Lat != 0 || point.Lng != 0 {
validPoints = append(validPoints, point)
}
}
data.Route = validPoints
return data
}
type RouteData struct {
Route []RoutePoint `json:"route"`
Suggestions []TravelSuggestion `json:"suggestions"`
}
func (s *Service) extractRouteFromResponse(_ context.Context, response string) *RouteData {
var jsonStr string
start := strings.Index(response, "```json")
if start != -1 {
start += 7
end := strings.Index(response[start:], "```")
if end != -1 {
jsonStr = strings.TrimSpace(response[start : start+end])
}
}
if jsonStr == "" {
start = strings.Index(response, "```")
if start != -1 {
start += 3
if start < len(response) {
for start < len(response) && (response[start] == '\n' || response[start] == '\r') {
start++
}
end := strings.Index(response[start:], "```")
if end != -1 {
candidate := strings.TrimSpace(response[start : start+end])
if strings.HasPrefix(candidate, "{") {
jsonStr = candidate
}
}
}
}
}
if jsonStr == "" {
routeStart := strings.Index(response, `"route"`)
if routeStart != -1 {
braceStart := strings.LastIndex(response[:routeStart], "{")
if braceStart != -1 {
depth := 0
braceEnd := -1
for i := braceStart; i < len(response); i++ {
if response[i] == '{' {
depth++
} else if response[i] == '}' {
depth--
if depth == 0 {
braceEnd = i + 1
break
}
}
}
if braceEnd != -1 {
jsonStr = response[braceStart:braceEnd]
}
}
}
}
if jsonStr == "" {
return nil
}
var rawResult struct {
Route []struct {
ID string `json:"id"`
Lat interface{} `json:"lat"`
Lng interface{} `json:"lng"`
Name string `json:"name"`
Address string `json:"address,omitempty"`
Type string `json:"type"`
AIComment string `json:"aiComment,omitempty"`
Duration interface{} `json:"duration,omitempty"`
Order interface{} `json:"order"`
} `json:"route"`
Suggestions []struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Lat interface{} `json:"lat,omitempty"`
Lng interface{} `json:"lng,omitempty"`
} `json:"suggestions"`
}
if err := json.Unmarshal([]byte(jsonStr), &rawResult); err != nil {
fmt.Printf("[travel] JSON parse error: %v, json: %s\n", err, jsonStr)
return nil
}
result := &RouteData{
Route: make([]RoutePoint, 0, len(rawResult.Route)),
Suggestions: make([]TravelSuggestion, 0, len(rawResult.Suggestions)),
}
for i, raw := range rawResult.Route {
point := RoutePoint{
ID: raw.ID,
Name: raw.Name,
Address: raw.Address,
Type: RoutePointType(raw.Type),
AIComment: raw.AIComment,
Order: i + 1,
}
if point.ID == "" {
point.ID = uuid.New().String()
}
point.Lat = toFloat64(raw.Lat)
point.Lng = toFloat64(raw.Lng)
point.Duration = toInt(raw.Duration)
if orderVal := toInt(raw.Order); orderVal > 0 {
point.Order = orderVal
}
if point.Type == "" {
point.Type = RoutePointCustom
}
if point.Name != "" {
result.Route = append(result.Route, point)
}
}
for _, raw := range rawResult.Suggestions {
sug := TravelSuggestion{
ID: raw.ID,
Type: raw.Type,
Title: raw.Title,
Description: raw.Description,
}
if sug.ID == "" {
sug.ID = uuid.New().String()
}
sug.Lat = toFloat64(raw.Lat)
sug.Lng = toFloat64(raw.Lng)
result.Suggestions = append(result.Suggestions, sug)
}
fmt.Printf("[travel] Extracted %d route points, %d suggestions\n", len(result.Route), len(result.Suggestions))
return result
}
func toFloat64(v interface{}) float64 {
if v == nil {
return 0
}
switch val := v.(type) {
case float64:
return val
case float32:
return float64(val)
case int:
return float64(val)
case int64:
return float64(val)
case string:
f, _ := strconv.ParseFloat(val, 64)
return f
}
return 0
}
func toInt(v interface{}) int {
if v == nil {
return 0
}
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case float32:
return int(val)
case string:
i, _ := strconv.Atoi(val)
return i
}
return 0
}
func (s *Service) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
if s.useRussianAPIs && s.travelPayouts != nil {
return s.travelPayouts.SearchFlights(ctx, req)
}
return s.amadeus.SearchFlights(ctx, req)
}
func (s *Service) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) {
return s.amadeus.SearchHotels(ctx, req)
}
func (s *Service) GetRoute(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) {
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
transport := mapProfileToTwoGISTransport(profile)
dir, err := s.twoGIS.GetRoute(ctx, points, transport)
if err == nil {
return dir, nil
}
fmt.Printf("[travel] 2GIS routing failed (transport=%s): %v, trying OpenRouteService\n", transport, err)
}
if s.openRoute != nil && s.openRoute.HasAPIKey() {
return s.openRoute.GetDirections(ctx, points, profile)
}
return nil, fmt.Errorf("no routing provider available")
}
func mapProfileToTwoGISTransport(profile string) string {
switch profile {
case "driving-car", "driving", "car":
return "driving"
case "taxi":
return "taxi"
case "foot-walking", "walking", "pedestrian":
return "walking"
case "cycling-regular", "cycling", "bicycle":
return "bicycle"
default:
return "driving"
}
}
func (s *Service) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
if query == "" {
return nil, fmt.Errorf("empty query")
}
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
loc, err := s.twoGIS.Geocode(ctx, query)
if err == nil && loc != nil {
fmt.Printf("[travel] 2GIS geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng)
return loc, nil
}
fmt.Printf("[travel] 2GIS geocode failed for '%s': %v\n", query, err)
}
if s.openRoute != nil && s.openRoute.HasAPIKey() {
loc, err := s.openRoute.Geocode(ctx, query)
if err == nil && loc != nil {
fmt.Printf("[travel] OpenRoute geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng)
return loc, nil
}
fmt.Printf("[travel] OpenRoute geocode failed for '%s': %v\n", query, err)
}
return nil, fmt.Errorf("geocode failed for '%s': no API keys configured", query)
}
func (s *Service) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) {
return s.openRoute.SearchPOI(ctx, req)
}
func (s *Service) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) {
if s.useRussianAPIs && s.travelPayouts != nil {
return s.travelPayouts.GetPopularDestinations(ctx, origin)
}
return nil, nil
}
func (s *Service) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) {
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
return s.twoGIS.SearchPlaces(ctx, query, lat, lng, radius)
}
return nil, fmt.Errorf("2GIS API key not configured")
}
func (s *Service) CreateTrip(ctx context.Context, trip *Trip) error {
return s.repo.CreateTrip(ctx, trip)
}
func (s *Service) GetTrip(ctx context.Context, id string) (*Trip, error) {
return s.repo.GetTrip(ctx, id)
}
func (s *Service) GetUserTrips(ctx context.Context, userID string, limit, offset int) ([]Trip, error) {
return s.repo.GetTripsByUser(ctx, userID, limit, offset)
}
func (s *Service) UpdateTrip(ctx context.Context, trip *Trip) error {
return s.repo.UpdateTrip(ctx, trip)
}
func (s *Service) DeleteTrip(ctx context.Context, id string) error {
return s.repo.DeleteTrip(ctx, id)
}
func (s *Service) GetUpcomingTrips(ctx context.Context, userID string) ([]Trip, error) {
trips, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusPlanned)
if err != nil {
return nil, err
}
booked, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusBooked)
if err != nil {
return nil, err
}
now := time.Now()
var upcoming []Trip
for _, t := range append(trips, booked...) {
if t.StartDate.After(now) {
upcoming = append(upcoming, t)
}
}
return upcoming, nil
}
func (s *Service) BuildRouteFromPoints(ctx context.Context, trip *Trip) (*RouteDirection, error) {
if len(trip.Route) < 2 {
return nil, fmt.Errorf("need at least 2 points for route")
}
points := make([]GeoLocation, len(trip.Route))
for i, p := range trip.Route {
points[i] = GeoLocation{
Lat: p.Lat,
Lng: p.Lng,
Name: p.Name,
}
}
return s.openRoute.GetDirections(ctx, points, "driving-car")
}
func (s *Service) EnrichTripWithAI(ctx context.Context, trip *Trip) error {
if len(trip.Route) == 0 {
return nil
}
for i := range trip.Route {
point := &trip.Route[i]
if point.AIComment == "" {
pois, err := s.openRoute.SearchPOI(ctx, POISearchRequest{
Lat: point.Lat,
Lng: point.Lng,
Radius: 500,
Limit: 5,
})
if err == nil && len(pois) > 0 {
var nearbyNames []string
for _, poi := range pois {
if poi.Name != "" && poi.Name != point.Name {
nearbyNames = append(nearbyNames, poi.Name)
}
}
if len(nearbyNames) > 0 {
point.AIComment = fmt.Sprintf("Рядом: %s", strings.Join(nearbyNames, ", "))
}
}
}
}
return nil
}

View 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)
}

View File

@@ -0,0 +1,465 @@
package travel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type TwoGISClient struct {
apiKey string
baseURL string
httpClient *http.Client
}
type TwoGISConfig struct {
APIKey string
BaseURL string
}
func (c *TwoGISClient) HasAPIKey() bool {
return c.apiKey != ""
}
func NewTwoGISClient(cfg TwoGISConfig) *TwoGISClient {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = "https://catalog.api.2gis.com"
}
return &TwoGISClient{
apiKey: cfg.APIKey,
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
type twoGISResponse struct {
Meta struct {
Code int `json:"code"`
} `json:"meta"`
Result struct {
Items []twoGISItem `json:"items"`
Total int `json:"total"`
} `json:"result"`
}
type twoGISItem struct {
ID string `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
AddressName string `json:"address_name"`
Type string `json:"type"`
Point *struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"point"`
Address *struct {
Components []struct {
Type string `json:"type"`
Name string `json:"name"`
Country string `json:"country,omitempty"`
} `json:"components,omitempty"`
} `json:"address,omitempty"`
PurposeName string `json:"purpose_name,omitempty"`
Reviews *twoGISReviews `json:"reviews,omitempty"`
Schedule map[string]twoGISScheduleDay `json:"schedule,omitempty"`
}
type twoGISReviews struct {
GeneralRating float64 `json:"general_rating"`
GeneralReviewCount int `json:"general_review_count"`
OrgRating float64 `json:"org_rating"`
OrgReviewCount int `json:"org_review_count"`
}
type twoGISScheduleDay struct {
WorkingHours []struct {
From string `json:"from"`
To string `json:"to"`
} `json:"working_hours"`
}
func (c *TwoGISClient) doRequest(ctx context.Context, endpoint string, params url.Values) (*twoGISResponse, error) {
params.Set("key", c.apiKey)
fullURL := c.baseURL + endpoint + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
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("2GIS API error %d: %s", resp.StatusCode, string(body))
}
var result twoGISResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
if result.Meta.Code >= 400 {
return nil, fmt.Errorf("2GIS API meta error %d: %s", result.Meta.Code, string(body))
}
return &result, nil
}
func (c *TwoGISClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
params := url.Values{}
params.Set("q", query)
params.Set("fields", "items.point")
params.Set("type", "building,street,adm_div,adm_div.city,adm_div.place,adm_div.settlement,crossroad,attraction")
result, err := c.doRequest(ctx, "/3.0/items/geocode", params)
if err != nil {
return nil, err
}
if len(result.Result.Items) == 0 {
return nil, fmt.Errorf("location not found: %s", query)
}
item := result.Result.Items[0]
if item.Point == nil {
return nil, fmt.Errorf("no coordinates for: %s", query)
}
country := ""
if item.Address != nil {
for _, comp := range item.Address.Components {
if comp.Type == "country" {
country = comp.Name
break
}
}
}
name := item.Name
if name == "" {
name = item.FullName
}
return &GeoLocation{
Lat: item.Point.Lat,
Lng: item.Point.Lon,
Name: name,
Country: country,
}, nil
}
func (c *TwoGISClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) {
params := url.Values{}
params.Set("lat", fmt.Sprintf("%f", lat))
params.Set("lon", fmt.Sprintf("%f", lng))
params.Set("fields", "items.point")
result, err := c.doRequest(ctx, "/3.0/items/geocode", params)
if err != nil {
return nil, err
}
if len(result.Result.Items) == 0 {
return nil, fmt.Errorf("location not found at %f,%f", lat, lng)
}
item := result.Result.Items[0]
country := ""
if item.Address != nil {
for _, comp := range item.Address.Components {
if comp.Type == "country" {
country = comp.Name
break
}
}
}
name := item.Name
if name == "" {
name = item.FullName
}
return &GeoLocation{
Lat: lat,
Lng: lng,
Name: name,
Country: country,
}, nil
}
type TwoGISPlace 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"`
Rating float64 `json:"rating"`
ReviewCount int `json:"reviewCount"`
Schedule map[string]string `json:"schedule,omitempty"`
}
func (c *TwoGISClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) {
params := url.Values{}
params.Set("q", query)
params.Set("point", fmt.Sprintf("%f,%f", lng, lat))
params.Set("radius", fmt.Sprintf("%d", radius))
params.Set("fields", "items.point,items.address,items.reviews,items.schedule")
params.Set("page_size", "10")
result, err := c.doRequest(ctx, "/3.0/items", params)
if err != nil {
return nil, err
}
places := make([]TwoGISPlace, 0, len(result.Result.Items))
for _, item := range result.Result.Items {
if item.Point == nil {
continue
}
addr := item.AddressName
if addr == "" {
addr = item.FullName
}
place := TwoGISPlace{
ID: item.ID,
Name: item.Name,
Address: addr,
Lat: item.Point.Lat,
Lng: item.Point.Lon,
Type: item.Type,
Purpose: item.PurposeName,
}
if item.Reviews != nil {
place.Rating = item.Reviews.GeneralRating
place.ReviewCount = item.Reviews.GeneralReviewCount
if place.Rating == 0 {
place.Rating = item.Reviews.OrgRating
}
if place.ReviewCount == 0 {
place.ReviewCount = item.Reviews.OrgReviewCount
}
}
if item.Schedule != nil {
place.Schedule = formatSchedule(item.Schedule)
}
places = append(places, place)
}
return places, nil
}
func formatSchedule(sched map[string]twoGISScheduleDay) map[string]string {
dayOrder := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
dayRu := map[string]string{
"Mon": "Пн", "Tue": "Вт", "Wed": "Ср", "Thu": "Чт",
"Fri": "Пт", "Sat": "Сб", "Sun": "Вс",
}
result := make(map[string]string, len(sched))
for _, d := range dayOrder {
if day, ok := sched[d]; ok && len(day.WorkingHours) > 0 {
wh := day.WorkingHours[0]
result[dayRu[d]] = wh.From + "" + wh.To
}
}
return result
}
const twoGISRoutingBaseURL = "https://routing.api.2gis.com"
type twoGISRoutingRequest struct {
Points []twoGISRoutePoint `json:"points"`
Transport string `json:"transport"`
Output string `json:"output"`
Locale string `json:"locale"`
}
type twoGISRoutePoint struct {
Type string `json:"type"`
Lon float64 `json:"lon"`
Lat float64 `json:"lat"`
}
type twoGISRoutingResponse struct {
Message string `json:"message"`
Result []twoGISRoutingResult `json:"result"`
}
type twoGISRoutingResult struct {
ID string `json:"id"`
Algorithm string `json:"algorithm"`
TotalDistance int `json:"total_distance"`
TotalDuration int `json:"total_duration"`
Maneuvers []twoGISManeuver `json:"maneuvers"`
}
type twoGISManeuver struct {
ID string `json:"id"`
Comment string `json:"comment"`
Type string `json:"type"`
OutcomingPath *twoGISOutcomingPath `json:"outcoming_path,omitempty"`
}
type twoGISOutcomingPath struct {
Distance int `json:"distance"`
Duration int `json:"duration"`
Geometry []twoGISPathGeometry `json:"geometry"`
}
type twoGISPathGeometry struct {
Selection string `json:"selection"`
Length int `json:"length"`
}
func (c *TwoGISClient) GetRoute(ctx context.Context, points []GeoLocation, transport string) (*RouteDirection, error) {
if len(points) < 2 {
return nil, fmt.Errorf("at least 2 points required for routing")
}
if transport == "" {
transport = "driving"
}
routePoints := make([]twoGISRoutePoint, len(points))
for i, p := range points {
routePoints[i] = twoGISRoutePoint{
Type: "stop",
Lon: p.Lng,
Lat: p.Lat,
}
}
reqBody := twoGISRoutingRequest{
Points: routePoints,
Transport: transport,
Output: "detailed",
Locale: "ru",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal routing request: %w", err)
}
reqURL := fmt.Sprintf("%s/routing/7.0.0/global?key=%s", twoGISRoutingBaseURL, url.QueryEscape(c.apiKey))
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("create routing request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute routing request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read routing response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("2GIS Routing API error %d: %s", resp.StatusCode, string(body))
}
var routingResp twoGISRoutingResponse
if err := json.Unmarshal(body, &routingResp); err != nil {
return nil, fmt.Errorf("unmarshal routing response: %w", err)
}
if routingResp.Message != "" && routingResp.Message != "OK" && len(routingResp.Result) == 0 {
return nil, fmt.Errorf("2GIS Routing error: %s", routingResp.Message)
}
if len(routingResp.Result) == 0 {
return nil, fmt.Errorf("no route found")
}
route := routingResp.Result[0]
var allCoords [][2]float64
var steps []RouteStep
for _, m := range route.Maneuvers {
if m.OutcomingPath != nil {
for _, geom := range m.OutcomingPath.Geometry {
coords := parseWKTLineString(geom.Selection)
allCoords = append(allCoords, coords...)
}
if m.Comment != "" {
steps = append(steps, RouteStep{
Instruction: m.Comment,
Distance: float64(m.OutcomingPath.Distance),
Duration: float64(m.OutcomingPath.Duration),
Type: m.Type,
})
}
}
}
return &RouteDirection{
Geometry: RouteGeometry{
Coordinates: allCoords,
Type: "LineString",
},
Distance: float64(route.TotalDistance),
Duration: float64(route.TotalDuration),
Steps: steps,
}, nil
}
func parseWKTLineString(wkt string) [][2]float64 {
wkt = strings.TrimSpace(wkt)
if !strings.HasPrefix(wkt, "LINESTRING(") {
return nil
}
inner := wkt[len("LINESTRING(") : len(wkt)-1]
pairs := strings.Split(inner, ",")
coords := make([][2]float64, 0, len(pairs))
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
parts := strings.Fields(pair)
if len(parts) != 2 {
continue
}
lon, err1 := strconv.ParseFloat(parts[0], 64)
lat, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil {
continue
}
coords = append(coords, [2]float64{lon, lat})
}
return coords
}

View File

@@ -0,0 +1,203 @@
package travel
import "time"
type TripStatus string
const (
TripStatusPlanned TripStatus = "planned"
TripStatusBooked TripStatus = "booked"
TripStatusCompleted TripStatus = "completed"
TripStatusCancelled TripStatus = "cancelled"
)
type RoutePointType string
const (
RoutePointAirport RoutePointType = "airport"
RoutePointHotel RoutePointType = "hotel"
RoutePointAttraction RoutePointType = "attraction"
RoutePointRestaurant RoutePointType = "restaurant"
RoutePointTransport RoutePointType = "transport"
RoutePointCustom RoutePointType = "custom"
)
type Trip struct {
ID string `json:"id"`
UserID string `json:"userId"`
Title string `json:"title"`
Destination string `json:"destination"`
Description string `json:"description,omitempty"`
CoverImage string `json:"coverImage,omitempty"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
Route []RoutePoint `json:"route"`
Flights []FlightOffer `json:"flights,omitempty"`
Hotels []HotelOffer `json:"hotels,omitempty"`
TotalBudget float64 `json:"totalBudget,omitempty"`
Currency string `json:"currency"`
Status TripStatus `json:"status"`
AISummary string `json:"aiSummary,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type RoutePoint struct {
ID string `json:"id"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Name string `json:"name"`
Address string `json:"address,omitempty"`
Type RoutePointType `json:"type"`
AIComment string `json:"aiComment,omitempty"`
Duration int `json:"duration,omitempty"`
Cost float64 `json:"cost,omitempty"`
Order int `json:"order"`
Date string `json:"date,omitempty"`
Photos []string `json:"photos,omitempty"`
}
type FlightOffer struct {
ID string `json:"id"`
Airline string `json:"airline"`
AirlineLogo string `json:"airlineLogo,omitempty"`
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"`
CabinClass string `json:"cabinClass"`
SeatsAvailable int `json:"seatsAvailable,omitempty"`
BookingURL string `json:"bookingUrl,omitempty"`
}
type HotelOffer 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,omitempty"`
Stars int `json:"stars,omitempty"`
Price float64 `json:"price"`
PricePerNight float64 `json:"pricePerNight"`
Currency string `json:"currency"`
CheckIn string `json:"checkIn"`
CheckOut string `json:"checkOut"`
RoomType string `json:"roomType,omitempty"`
Amenities []string `json:"amenities,omitempty"`
Photos []string `json:"photos,omitempty"`
BookingURL string `json:"bookingUrl,omitempty"`
}
type TravelPlanRequest struct {
Query string `json:"query"`
StartDate string `json:"startDate,omitempty"`
EndDate string `json:"endDate,omitempty"`
Travelers int `json:"travelers,omitempty"`
Budget float64 `json:"budget,omitempty"`
Currency string `json:"currency,omitempty"`
Preferences *TravelPreferences `json:"preferences,omitempty"`
History [][2]string `json:"history,omitempty"`
}
type TravelPreferences struct {
TravelStyle string `json:"travelStyle,omitempty"`
Interests []string `json:"interests,omitempty"`
AvoidTypes []string `json:"avoidTypes,omitempty"`
TransportModes []string `json:"transportModes,omitempty"`
}
type TravelSuggestion struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image,omitempty"`
Price float64 `json:"price,omitempty"`
Currency string `json:"currency,omitempty"`
Rating float64 `json:"rating,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lng float64 `json:"lng,omitempty"`
}
type GeoLocation struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Name string `json:"name,omitempty"`
Country string `json:"country,omitempty"`
}
type RouteDirection struct {
Geometry RouteGeometry `json:"geometry"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Steps []RouteStep `json:"steps,omitempty"`
}
type RouteGeometry struct {
Coordinates [][2]float64 `json:"coordinates"`
Type string `json:"type"`
}
type RouteStep struct {
Instruction string `json:"instruction"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Type string `json:"type"`
}
type FlightSearchRequest struct {
Origin string `json:"origin"`
Destination string `json:"destination"`
DepartureDate string `json:"departureDate"`
ReturnDate string `json:"returnDate,omitempty"`
Adults int `json:"adults"`
Children int `json:"children,omitempty"`
CabinClass string `json:"cabinClass,omitempty"`
MaxPrice int `json:"maxPrice,omitempty"`
Currency string `json:"currency,omitempty"`
}
type HotelSearchRequest struct {
CityCode string `json:"cityCode"`
Lat float64 `json:"lat,omitempty"`
Lng float64 `json:"lng,omitempty"`
Radius int `json:"radius,omitempty"`
CheckIn string `json:"checkIn"`
CheckOut string `json:"checkOut"`
Adults int `json:"adults"`
Rooms int `json:"rooms,omitempty"`
MaxPrice int `json:"maxPrice,omitempty"`
Currency string `json:"currency,omitempty"`
Rating int `json:"rating,omitempty"`
}
type POISearchRequest struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Radius int `json:"radius"`
Categories []string `json:"categories,omitempty"`
Limit int `json:"limit,omitempty"`
}
type POI 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,omitempty"`
Phone string `json:"phone,omitempty"`
Website string `json:"website,omitempty"`
OpeningHours string `json:"openingHours,omitempty"`
Rating float64 `json:"rating,omitempty"`
Distance float64 `json:"distance,omitempty"`
}