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:
540
backend/cmd/travel-svc/main.go
Normal file
540
backend/cmd/travel-svc/main.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/travel"
|
||||
"github.com/gooseek/backend/pkg/middleware"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("Failed to ping database: %v", err)
|
||||
}
|
||||
|
||||
repo := travel.NewRepository(db)
|
||||
if err := repo.InitSchema(context.Background()); err != nil {
|
||||
log.Printf("Warning: Failed to init schema: %v", err)
|
||||
}
|
||||
|
||||
llmCfg := llm.ProviderConfig{
|
||||
ProviderID: getEnv("LLM_PROVIDER", "timeweb"),
|
||||
ModelKey: getEnv("LLM_MODEL", "gpt-4o-mini"),
|
||||
BaseURL: os.Getenv("TIMEWEB_API_BASE_URL"),
|
||||
APIKey: os.Getenv("TIMEWEB_API_KEY"),
|
||||
AgentAccessID: os.Getenv("TIMEWEB_AGENT_ACCESS_ID"),
|
||||
}
|
||||
|
||||
llmBaseClient, err := llm.NewClient(llmCfg)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to create LLM client: %v", err)
|
||||
}
|
||||
|
||||
var llmClient travel.LLMClient
|
||||
if llmBaseClient != nil {
|
||||
llmClient = travel.NewLLMClientAdapter(llmBaseClient)
|
||||
}
|
||||
|
||||
useRussianAPIs := getEnv("USE_RUSSIAN_APIS", "true") == "true"
|
||||
|
||||
svc := travel.NewService(travel.ServiceConfig{
|
||||
Repository: repo,
|
||||
AmadeusConfig: travel.AmadeusConfig{
|
||||
APIKey: os.Getenv("AMADEUS_API_KEY"),
|
||||
APISecret: os.Getenv("AMADEUS_API_SECRET"),
|
||||
BaseURL: getEnv("AMADEUS_BASE_URL", "https://test.api.amadeus.com"),
|
||||
},
|
||||
OpenRouteConfig: travel.OpenRouteConfig{
|
||||
APIKey: os.Getenv("OPENROUTE_API_KEY"),
|
||||
BaseURL: getEnv("OPENROUTE_BASE_URL", "https://api.openrouteservice.org"),
|
||||
},
|
||||
TravelPayoutsConfig: travel.TravelPayoutsConfig{
|
||||
Token: os.Getenv("TRAVELPAYOUTS_TOKEN"),
|
||||
Marker: os.Getenv("TRAVELPAYOUTS_MARKER"),
|
||||
},
|
||||
TwoGISConfig: travel.TwoGISConfig{
|
||||
APIKey: os.Getenv("TWOGIS_API_KEY"),
|
||||
},
|
||||
LLMClient: llmClient,
|
||||
UseRussianAPIs: useRussianAPIs,
|
||||
})
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
})
|
||||
|
||||
app.Use(logger.New())
|
||||
app.Use(cors.New())
|
||||
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "service": "travel-svc"})
|
||||
})
|
||||
|
||||
jwtConfig := middleware.JWTConfig{
|
||||
Secret: os.Getenv("JWT_SECRET"),
|
||||
AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"),
|
||||
}
|
||||
|
||||
jwtOptional := middleware.JWTConfig{
|
||||
Secret: os.Getenv("JWT_SECRET"),
|
||||
AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"),
|
||||
AllowGuest: true,
|
||||
}
|
||||
|
||||
api := app.Group("/api/v1/travel")
|
||||
|
||||
api.Post("/plan", middleware.JWT(jwtOptional), handlePlanTrip(svc))
|
||||
|
||||
api.Get("/trips", middleware.JWT(jwtOptional), handleGetTrips(svc))
|
||||
api.Post("/trips", middleware.JWT(jwtConfig), handleCreateTrip(svc))
|
||||
api.Get("/trips/:id", middleware.JWT(jwtOptional), handleGetTrip(svc))
|
||||
api.Put("/trips/:id", middleware.JWT(jwtConfig), handleUpdateTrip(svc))
|
||||
api.Delete("/trips/:id", middleware.JWT(jwtConfig), handleDeleteTrip(svc))
|
||||
api.Get("/trips/upcoming", middleware.JWT(jwtOptional), handleGetUpcoming(svc))
|
||||
|
||||
api.Get("/flights", middleware.JWT(jwtOptional), handleSearchFlights(svc))
|
||||
api.Post("/flights", middleware.JWT(jwtOptional), handleSearchFlightsPost(svc))
|
||||
api.Get("/hotels", middleware.JWT(jwtOptional), handleSearchHotels(svc))
|
||||
api.Post("/hotels", middleware.JWT(jwtOptional), handleSearchHotelsPost(svc))
|
||||
api.Post("/route", middleware.JWT(jwtOptional), handleGetRoute(svc))
|
||||
api.Get("/geocode", middleware.JWT(jwtOptional), handleGeocode(svc))
|
||||
api.Get("/poi", middleware.JWT(jwtOptional), handleSearchPOI(svc))
|
||||
api.Post("/poi", middleware.JWT(jwtOptional), handleSearchPOIPost(svc))
|
||||
api.Post("/places", middleware.JWT(jwtOptional), handleSearchPlaces(svc))
|
||||
|
||||
port := getEnvInt("PORT", 3035)
|
||||
log.Printf("travel-svc listening on :%d", port)
|
||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||
}
|
||||
|
||||
func handlePlanTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.TravelPlanRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "application/x-ndjson")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Transfer-Encoding", "chunked")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
ctx := context.Background()
|
||||
if err := svc.PlanTrip(ctx, req, w); err != nil {
|
||||
log.Printf("Plan trip error: %v", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetTrips(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
userID, _ := c.Locals("userId").(string)
|
||||
if userID == "" {
|
||||
return c.JSON(fiber.Map{"trips": []interface{}{}})
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 20)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
|
||||
trips, err := svc.GetUserTrips(c.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"trips": trips})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(string)
|
||||
|
||||
var trip travel.Trip
|
||||
if err := c.BodyParser(&trip); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
trip.UserID = userID
|
||||
if trip.Currency == "" {
|
||||
trip.Currency = "RUB"
|
||||
}
|
||||
if trip.Status == "" {
|
||||
trip.Status = travel.TripStatusPlanned
|
||||
}
|
||||
|
||||
if err := svc.CreateTrip(c.Context(), &trip); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(201).JSON(trip)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
trip, err := svc.GetTrip(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if trip == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Trip not found"})
|
||||
}
|
||||
|
||||
return c.JSON(trip)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
userID := c.Locals("userId").(string)
|
||||
|
||||
existing, err := svc.GetTrip(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if existing == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Trip not found"})
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
||||
}
|
||||
|
||||
var trip travel.Trip
|
||||
if err := c.BodyParser(&trip); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
trip.ID = id
|
||||
trip.UserID = userID
|
||||
|
||||
if err := svc.UpdateTrip(c.Context(), &trip); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(trip)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
userID := c.Locals("userId").(string)
|
||||
|
||||
existing, err := svc.GetTrip(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if existing == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Trip not found"})
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
||||
}
|
||||
|
||||
if err := svc.DeleteTrip(c.Context(), id); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUpcoming(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
userID, _ := c.Locals("userId").(string)
|
||||
if userID == "" {
|
||||
return c.JSON(fiber.Map{"trips": []interface{}{}})
|
||||
}
|
||||
|
||||
trips, err := svc.GetUpcomingTrips(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"trips": trips})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchFlights(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
req := travel.FlightSearchRequest{
|
||||
Origin: c.Query("origin"),
|
||||
Destination: c.Query("destination"),
|
||||
DepartureDate: c.Query("departureDate"),
|
||||
ReturnDate: c.Query("returnDate"),
|
||||
Adults: c.QueryInt("adults", 1),
|
||||
Children: c.QueryInt("children", 0),
|
||||
CabinClass: c.Query("cabinClass"),
|
||||
MaxPrice: c.QueryInt("maxPrice", 0),
|
||||
Currency: c.Query("currency", "RUB"),
|
||||
}
|
||||
|
||||
if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"})
|
||||
}
|
||||
|
||||
flights, err := svc.SearchFlights(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"flights": flights})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchHotels(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
req := travel.HotelSearchRequest{
|
||||
CityCode: c.Query("cityCode"),
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Radius: c.QueryInt("radius", 5),
|
||||
CheckIn: c.Query("checkIn"),
|
||||
CheckOut: c.Query("checkOut"),
|
||||
Adults: c.QueryInt("adults", 1),
|
||||
Rooms: c.QueryInt("rooms", 1),
|
||||
MaxPrice: c.QueryInt("maxPrice", 0),
|
||||
Currency: c.Query("currency", "RUB"),
|
||||
Rating: c.QueryInt("rating", 0),
|
||||
}
|
||||
|
||||
if req.CityCode == "" || req.CheckIn == "" || req.CheckOut == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "cityCode, checkIn, checkOut required"})
|
||||
}
|
||||
|
||||
hotels, err := svc.SearchHotels(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"hotels": hotels})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetRoute(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Points []travel.GeoLocation `json:"points"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
if len(req.Points) < 2 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "At least 2 points required"})
|
||||
}
|
||||
|
||||
route, err := svc.GetRoute(c.Context(), req.Points, req.Profile)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(route)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGeocode(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
query := c.Query("query")
|
||||
if query == "" {
|
||||
query = c.Query("q")
|
||||
}
|
||||
if query == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "query parameter required"})
|
||||
}
|
||||
|
||||
location, err := svc.Geocode(c.Context(), query)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(location)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchPOI(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
if lat == 0 || lng == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"})
|
||||
}
|
||||
|
||||
req := travel.POISearchRequest{
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Radius: c.QueryInt("radius", 1000),
|
||||
Limit: c.QueryInt("limit", 20),
|
||||
}
|
||||
|
||||
if categories := c.Query("categories"); categories != "" {
|
||||
req.Categories = strings.Split(categories, ",")
|
||||
}
|
||||
|
||||
pois, err := svc.SearchPOI(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"pois": pois})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchFlightsPost(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.FlightSearchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"})
|
||||
}
|
||||
if req.Adults == 0 {
|
||||
req.Adults = 1
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "RUB"
|
||||
}
|
||||
|
||||
flights, err := svc.SearchFlights(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(flights)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchHotelsPost(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.HotelSearchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.CheckIn == "" || req.CheckOut == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "checkIn, checkOut required"})
|
||||
}
|
||||
if req.Adults == 0 {
|
||||
req.Adults = 1
|
||||
}
|
||||
if req.Rooms == 0 {
|
||||
req.Rooms = 1
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "RUB"
|
||||
}
|
||||
|
||||
hotels, err := svc.SearchHotels(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(hotels)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchPOIPost(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.POISearchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.Lat == 0 || req.Lng == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"})
|
||||
}
|
||||
if req.Radius == 0 {
|
||||
req.Radius = 1000
|
||||
}
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 20
|
||||
}
|
||||
|
||||
pois, err := svc.SearchPOI(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(pois)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchPlaces(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Radius int `json:"radius"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
if req.Query == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "query required"})
|
||||
}
|
||||
if req.Lat == 0 || req.Lng == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"})
|
||||
}
|
||||
if req.Radius == 0 {
|
||||
req.Radius = 5000
|
||||
}
|
||||
|
||||
places, err := svc.SearchPlaces(c.Context(), req.Query, req.Lat, req.Lng, req.Radius)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(places)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user