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