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:
465
backend/internal/travel/twogis.go
Normal file
465
backend/internal/travel/twogis.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user