Files
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

466 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}