- 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
466 lines
11 KiB
Go
466 lines
11 KiB
Go
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
|
||
}
|