Files
gooseek/backend/internal/agent/travel_data_client.go
home ab48a0632b
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped
feat: CI/CD pipeline + Learning/Medicine/Travel services
- Add Gitea Actions workflow for automated build & deploy
- Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc
- Update kustomization for localhost:5000 registry
- Add ingress for gooseek.ru and api.gooseek.ru
- Learning cabinet with onboarding, courses, sandbox integration
- Medicine service with symptom analysis and doctor matching
- Travel service with itinerary planning
- Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner)

Made-with: Cursor
2026-03-02 20:25:44 +03:00

488 lines
13 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
var (
geocodeCache = make(map[string]*GeoResult)
geocodeCacheMu sync.RWMutex
)
// TravelDataClient calls the travel-svc data-layer endpoints.
type TravelDataClient struct {
baseURL string
httpClient *http.Client
maxRetries int
}
func NewTravelDataClient(baseURL string) *TravelDataClient {
return &TravelDataClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
maxRetries: 2,
}
}
func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
// http.Request.Clone does NOT recreate the Body. If we retry a request with a Body,
// we must be able to recreate it; otherwise retries will send an empty body and may
// fail with ContentLength/body length mismatch.
var bodyCopy []byte
if req.Body != nil && req.GetBody == nil {
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read request body for retry: %w", err)
}
_ = req.Body.Close()
bodyCopy = b
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyCopy)), nil
}
req.Body = io.NopCloser(bytes.NewReader(bodyCopy))
req.ContentLength = int64(len(bodyCopy))
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(attempt*500) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
reqAttempt := req.Clone(ctx)
if req.GetBody != nil {
rc, err := req.GetBody()
if err != nil {
lastErr = err
continue
}
reqAttempt.Body = rc
reqAttempt.ContentLength = req.ContentLength
}
resp, err := c.httpClient.Do(reqAttempt)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
continue
}
return resp, nil
}
return nil, fmt.Errorf("all %d retries failed: %w", c.maxRetries+1, lastErr)
}
func (c *TravelDataClient) Geocode(ctx context.Context, query string) (*GeoResult, error) {
// Check in-memory cache first
geocodeCacheMu.RLock()
if cached, ok := geocodeCache[query]; ok {
geocodeCacheMu.RUnlock()
return cached, nil
}
geocodeCacheMu.RUnlock()
u := fmt.Sprintf("%s/api/v1/travel/geocode?query=%s", c.baseURL, url.QueryEscape(query))
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
resp, err := c.doWithRetry(ctx, req)
if err != nil {
return nil, fmt.Errorf("geocode request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("geocode returned %d: %s", resp.StatusCode, string(body))
}
var result GeoResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("geocode decode error: %w", err)
}
geocodeCacheMu.Lock()
geocodeCache[query] = &result
geocodeCacheMu.Unlock()
return &result, nil
}
func (c *TravelDataClient) SearchPOI(ctx context.Context, lat, lng float64, radius int, categories []string) ([]POICard, error) {
body := map[string]interface{}{
"lat": lat,
"lng": lng,
"radius": radius,
"limit": 20,
}
if len(categories) > 0 {
body["categories"] = categories
}
jsonBody, _ := json.Marshal(body)
u := fmt.Sprintf("%s/api/v1/travel/poi", c.baseURL)
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("poi search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("poi search returned %d", resp.StatusCode)
}
var rawPOIs []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"`
Rating float64 `json:"rating"`
Distance float64 `json:"distance"`
}
if err := json.NewDecoder(resp.Body).Decode(&rawPOIs); err != nil {
return nil, err
}
cards := make([]POICard, 0, len(rawPOIs))
for _, p := range rawPOIs {
cards = append(cards, POICard{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Address: p.Address,
Lat: p.Lat,
Lng: p.Lng,
Rating: p.Rating,
})
}
return cards, nil
}
func (c *TravelDataClient) SearchFlights(ctx context.Context, origin, destination, date string, adults int) ([]TransportOption, error) {
body := map[string]interface{}{
"origin": origin,
"destination": destination,
"departureDate": date,
"adults": adults,
}
jsonBody, _ := json.Marshal(body)
u := fmt.Sprintf("%s/api/v1/travel/flights", c.baseURL)
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("flights search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("flights search returned %d", resp.StatusCode)
}
var rawFlights []struct {
ID string `json:"id"`
Airline string `json:"airline"`
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"`
BookingURL string `json:"bookingUrl"`
}
if err := json.NewDecoder(resp.Body).Decode(&rawFlights); err != nil {
return nil, err
}
options := make([]TransportOption, 0, len(rawFlights))
for _, f := range rawFlights {
options = append(options, TransportOption{
ID: f.ID,
Mode: "flight",
From: f.DepartureCity,
To: f.ArrivalCity,
Departure: f.DepartureTime,
Arrival: f.ArrivalTime,
DurationMin: f.Duration,
Price: f.Price,
Currency: f.Currency,
Provider: f.Airline,
BookingURL: f.BookingURL,
Airline: f.Airline,
FlightNum: f.FlightNumber,
Stops: f.Stops,
})
}
return options, nil
}
func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, checkIn, checkOut string, adults int) ([]HotelCard, error) {
body := map[string]interface{}{
"lat": lat,
"lng": lng,
"radius": 10,
"checkIn": checkIn,
"checkOut": checkOut,
"adults": adults,
}
jsonBody, _ := json.Marshal(body)
u := fmt.Sprintf("%s/api/v1/travel/hotels", c.baseURL)
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("hotels search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("hotels search returned %d", resp.StatusCode)
}
var rawHotels []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"`
Stars int `json:"stars"`
Price float64 `json:"price"`
PricePerNight float64 `json:"pricePerNight"`
Currency string `json:"currency"`
CheckIn string `json:"checkIn"`
CheckOut string `json:"checkOut"`
Amenities []string `json:"amenities"`
Photos []string `json:"photos"`
BookingURL string `json:"bookingUrl"`
}
if err := json.NewDecoder(resp.Body).Decode(&rawHotels); err != nil {
return nil, err
}
cards := make([]HotelCard, 0, len(rawHotels))
for _, h := range rawHotels {
cards = append(cards, HotelCard{
ID: h.ID,
Name: h.Name,
Stars: h.Stars,
Rating: h.Rating,
ReviewCount: h.ReviewCount,
PricePerNight: h.PricePerNight,
TotalPrice: h.Price,
Currency: h.Currency,
Address: h.Address,
Lat: h.Lat,
Lng: h.Lng,
BookingURL: h.BookingURL,
Photos: h.Photos,
Amenities: h.Amenities,
CheckIn: h.CheckIn,
CheckOut: h.CheckOut,
})
}
return cards, nil
}
// PlaceResult represents a place from 2GIS Places API.
type PlaceResult 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 *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) {
body := map[string]interface{}{
"query": query,
"lat": lat,
"lng": lng,
"radius": radius,
}
jsonBody, _ := json.Marshal(body)
u := fmt.Sprintf("%s/api/v1/travel/places", c.baseURL)
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doWithRetry(ctx, req)
if err != nil {
return nil, fmt.Errorf("places search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("places search returned %d: %s", resp.StatusCode, string(respBody))
}
var places []PlaceResult
if err := json.NewDecoder(resp.Body).Decode(&places); err != nil {
return nil, fmt.Errorf("places decode error: %w", err)
}
return places, nil
}
func (c *TravelDataClient) GetRoute(ctx context.Context, points []MapPoint, transport string) (*RouteDirectionResult, error) {
if len(points) < 2 {
return nil, fmt.Errorf("need at least 2 points for route")
}
if transport == "" {
transport = "driving"
}
coords := make([]map[string]float64, len(points))
for i, p := range points {
coords[i] = map[string]float64{"lat": p.Lat, "lng": p.Lng}
}
body := map[string]interface{}{
"points": coords,
"profile": transport,
}
jsonBody, _ := json.Marshal(body)
u := fmt.Sprintf("%s/api/v1/travel/route", c.baseURL)
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("route request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("route returned %d: %s", resp.StatusCode, string(respBody))
}
var result RouteDirectionResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
// GetRouteSegments builds routes between each consecutive pair of points.
func (c *TravelDataClient) GetRouteSegments(ctx context.Context, points []MapPoint, transport string) ([]RouteSegmentResult, error) {
if len(points) < 2 {
return nil, nil
}
segments := make([]RouteSegmentResult, 0, len(points)-1)
for i := 0; i < len(points)-1; i++ {
pair := []MapPoint{points[i], points[i+1]}
dir, err := c.GetRoute(ctx, pair, transport)
if err != nil {
segments = append(segments, RouteSegmentResult{
FromName: points[i].Label,
ToName: points[i+1].Label,
})
continue
}
segments = append(segments, RouteSegmentResult{
FromName: points[i].Label,
ToName: points[i+1].Label,
Distance: dir.Distance,
Duration: dir.Duration,
Geometry: dir.Geometry,
})
}
return segments, nil
}
type GeoResult struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Name string `json:"name"`
Country string `json:"country"`
}
type RouteDirectionResult struct {
Geometry RouteGeometryResult `json:"geometry"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Steps []RouteStepResult `json:"steps,omitempty"`
}
type RouteGeometryResult struct {
Coordinates [][2]float64 `json:"coordinates"`
Type string `json:"type"`
}
type RouteStepResult struct {
Instruction string `json:"instruction"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Type string `json:"type"`
}
type RouteSegmentResult struct {
FromName string `json:"from"`
ToName string `json:"to"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Geometry RouteGeometryResult `json:"geometry,omitempty"`
}