diff --git a/cmd/server/main.go b/cmd/server/main.go index 89da1fe..71df5df 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -105,6 +105,7 @@ func main() { reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo) consentH := handlers.NewConsentHandlers(consentRepo, auditRepo) photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo) + placesH := handlers.NewPlacesHandler() _ = rdb @@ -202,6 +203,7 @@ func main() { protected.Post("/me/photo", photoH.UploadPhoto) protected.Get("/search/nearby", h.SearchNearby) protected.Get("/users/:id", h.GetUserPublic) + protected.Get("/places/nearby", placesH.Nearby) protected.Post("/chats", chatH.EnsureChat) protected.Get("/chats", chatH.ListChats) diff --git a/internal/handlers/places.go b/internal/handlers/places.go new file mode 100644 index 0000000..c6ba8c3 --- /dev/null +++ b/internal/handlers/places.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + + "github.com/buhapp/backend/internal/places" +) + +type PlacesHandler struct { + Places *places.Client +} + +func NewPlacesHandler() *PlacesHandler { + return &PlacesHandler{Places: places.New()} +} + +// GET /api/v1/places/nearby?lat=..&lng=..&radius=..&limit=.. +func (h *PlacesHandler) Nearby(c *fiber.Ctx) error { + lat, err := strconv.ParseFloat(c.Query("lat"), 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "lat required"}) + } + lng, err := strconv.ParseFloat(c.Query("lng"), 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "lng required"}) + } + radius, _ := strconv.ParseFloat(c.Query("radius", "3"), 64) + if radius < 0.5 { + radius = 0.5 + } + if radius > 25 { + radius = 25 + } + limit, _ := strconv.Atoi(c.Query("limit", "30")) + + results, err := h.Places.NearbyPlaces(c.UserContext(), lat, lng, radius, limit) + if err != nil { + // не критично — отдаём пустой массив с сообщением + return c.JSON(fiber.Map{ + "places": []places.Place{}, + "error": err.Error(), + "count": 0, + }) + } + return c.JSON(fiber.Map{ + "places": results, + "count": len(results), + }) +} diff --git a/internal/places/overpass.go b/internal/places/overpass.go new file mode 100644 index 0000000..5b3a104 --- /dev/null +++ b/internal/places/overpass.go @@ -0,0 +1,143 @@ +package places + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "net/url" + "strings" + "time" +) + +// Place — бар/ресторан/кафе в радиусе +type Place struct { + OSMID int64 `json:"osm_id"` + Name string `json:"name"` + Type string `json:"type"` + Cuisine string `json:"cuisine,omitempty"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Distance float64 `json:"distance_m"` + Address string `json:"address,omitempty"` + Phone string `json:"phone,omitempty"` + Website string `json:"website,omitempty"` + Hours string `json:"hours,omitempty"` +} + +type Client struct { + httpClient *http.Client + endpoint string +} + +func New() *Client { + return &Client{ + httpClient: &http.Client{Timeout: 8 * time.Second}, + endpoint: "https://overpass-api.de/api/interpreter", + } +} + +// NearbyPlaces — бары/рестораны/кафе рядом (Overpass API) +func (c *Client) NearbyPlaces(ctx context.Context, lat, lng, radiusKm float64, limit int) ([]Place, error) { + if limit <= 0 || limit > 100 { + limit = 30 + } + // bounding box + latDelta := radiusKm / 111.0 + lngDelta := radiusKm / (111.0 * math.Cos(lat*math.Pi/180)) + south := lat - latDelta + north := lat + latDelta + west := lng - lngDelta + east := lng + lngDelta + + query := fmt.Sprintf(`[out:json][timeout:8]; +( + node["amenity"~"bar|pub|restaurant|cafe|biergarten|nightclub"](%f,%f,%f,%f); + way["amenity"~"bar|pub|restaurant|cafe|biergarten|nightclub"](%f,%f,%f,%f); +); +out center %d;`, south, west, north, east, south, west, north, east, limit) + + data := url.Values{} + data.Set("data", query) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "BuhApp/0.7 (https://buhapp.mygoodservice.ru)") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("overpass status %d", resp.StatusCode) + } + + var raw struct { + Elements []struct { + Type string `json:"type"` + ID int64 `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Center *struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } `json:"center"` + Tags map[string]string `json:"tags"` + } `json:"elements"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + + out := make([]Place, 0, len(raw.Elements)) + for _, e := range raw.Elements { + plat, plng := e.Lat, e.Lon + if e.Center != nil { + plat, plng = e.Center.Lat, e.Center.Lon + } + if plat == 0 && plng == 0 { + continue + } + name := e.Tags["name"] + if name == "" { + name = e.Tags["name:ru"] + } + if name == "" { + continue + } + // расстояние от точки юзера + dist := haversine(lat, lng, plat, plng) * 1000 + if dist > radiusKm*1000 { + continue + } + out = append(out, Place{ + OSMID: e.ID, + Name: name, + Type: e.Tags["amenity"], + Cuisine: e.Tags["cuisine"], + Lat: plat, + Lng: plng, + Distance: dist, + Address: e.Tags["addr:full"], + Phone: e.Tags["phone"], + Website: e.Tags["website"], + Hours: e.Tags["opening_hours"], + }) + } + return out, nil +} + +func haversine(lat1, lng1, lat2, lng2 float64) float64 { + const R = 6371.0 + dLat := (lat2 - lat1) * math.Pi / 180 + dLng := (lng2 - lng1) * math.Pi / 180 + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLng/2)*math.Sin(dLng/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return R * c +}