Sprint 8: 2GIS tiles + places (бары/рестораны) nearby

- internal/places/overpass.go: запрос к Overpass API (OSM), bar/pub/restaurant/cafe/biergarten/nightclub
- internal/handlers/places.go: GET /api/v1/places/nearby (radius, limit)
- POST через application/x-www-form-urlencoded (раньше был broken — query params)
- 2GIS tile layer в map.js (вместо OSM)
- Кнопка 'Показать/Скрыть бары' + список под картой
- Invite to place: шлёт предложение встретиться в чат с собеседником
- Карточки с эмодзи по типу заведения, расстояние, время работы
This commit is contained in:
ga 2026-08-20 21:11:54 +00:00
parent 0f9e0ce596
commit 748b4a31fb
3 changed files with 196 additions and 0 deletions

View File

@ -105,6 +105,7 @@ func main() {
reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo) reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo)
consentH := handlers.NewConsentHandlers(consentRepo, auditRepo) consentH := handlers.NewConsentHandlers(consentRepo, auditRepo)
photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo) photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo)
placesH := handlers.NewPlacesHandler()
_ = rdb _ = rdb
@ -202,6 +203,7 @@ func main() {
protected.Post("/me/photo", photoH.UploadPhoto) protected.Post("/me/photo", photoH.UploadPhoto)
protected.Get("/search/nearby", h.SearchNearby) protected.Get("/search/nearby", h.SearchNearby)
protected.Get("/users/:id", h.GetUserPublic) protected.Get("/users/:id", h.GetUserPublic)
protected.Get("/places/nearby", placesH.Nearby)
protected.Post("/chats", chatH.EnsureChat) protected.Post("/chats", chatH.EnsureChat)
protected.Get("/chats", chatH.ListChats) protected.Get("/chats", chatH.ListChats)

View File

@ -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),
})
}

143
internal/places/overpass.go Normal file
View File

@ -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
}