buhapp-backend/internal/locations/repo.go
ga 0f9e0ce596 Sprint 7: race conditions + read_at + slog
1. chat.EnsureChat: ON CONFLICT DO UPDATE (race-safe)
2. chat.Send: tx.Begin/Commit (atomic INSERT message + UPDATE last_msg_at)
3. chat.MarkRead: добавил member-check (NOT a member -> 403)
4. chat.Message: +ReadAt field
5. migrations/0006: read_at column on messages
6. locations.Nearby: bounding-box prefilter + CTE (haversine только для отфильтрованных)
7. audit.Log: real metadata JSON passthrough (no more 'null' TODO)
8. main.go: slog JSON logger (был stdlib log)
9. WS: 'message_read' event от MarkRead
10. WS push: добавлены read/read_at/edited в message payload
11. Bumped v0.6.0
2026-08-20 21:04:56 +00:00

153 lines
4.2 KiB
Go

package locations
import (
"context"
"errors"
"math"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type Location struct {
UserID uuid.UUID `json:"user_id"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Visible bool `json:"visible"`
UpdatedAt time.Time `json:"updated_at"`
}
type Repo struct {
pool *pgxpool.Pool
}
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
// ApplyNoise — округляем координаты до ~300м точности (grid ~0.003 deg ≈ 333м)
func ApplyNoise(lat, lng float64) (float64, float64) {
const step = 0.003
return math.Round(lat/step)*step, math.Round(lng/step)*step
}
func (r *Repo) Upsert(ctx context.Context, loc *Location) error {
loc.Lat, loc.Lng = ApplyNoise(loc.Lat, loc.Lng)
_, err := r.pool.Exec(ctx, `
INSERT INTO user_locations (user_id, lat, lng, visible, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id) DO UPDATE
SET lat = EXCLUDED.lat, lng = EXCLUDED.lng,
visible = EXCLUDED.visible, updated_at = NOW()`,
loc.UserID, loc.Lat, loc.Lng, loc.Visible,
)
return err
}
func (r *Repo) SetVisible(ctx context.Context, userID uuid.UUID, visible bool) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO user_locations (user_id, lat, lng, visible, updated_at)
VALUES ($1, NULL, NULL, $2, NOW())
ON CONFLICT (user_id) DO UPDATE
SET visible = EXCLUDED.visible, updated_at = NOW()`,
userID, visible,
)
return err
}
func (r *Repo) Get(ctx context.Context, userID uuid.UUID) (*Location, error) {
loc := &Location{UserID: userID}
var lat, lng *float64
err := r.pool.QueryRow(ctx, `
SELECT lat, lng, visible, updated_at
FROM user_locations WHERE user_id=$1`, userID,
).Scan(&lat, &lng, &loc.Visible, &loc.UpdatedAt)
if err != nil {
return nil, err
}
if lat != nil {
loc.Lat = *lat
}
if lng != nil {
loc.Lng = *lng
}
return loc, nil
}
// Nearby — пользователи в радиусе radius км (по haversine), visible, не старше 1 часа
// Оптимизация: фильтруем сначала по bounding-box (lat/lng), потом haversine только для отфильтрованных.
// Это устраняет двойной расчёт haversine и даёт индекс если добавить GiST/btree на lat,lng.
type Nearby struct {
UserID uuid.UUID
Name string
PhotoURL string
Lat float64
Lng float64
DistanceM float64
}
func (r *Repo) Nearby(ctx context.Context, myID uuid.UUID, lat, lng, radiusKm float64, limit int) ([]Nearby, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
// bounding box: 1° lat ≈ 111 км, 1° lng ≈ 111 км * cos(lat)
latDelta := radiusKm / 111.0
lngDelta := radiusKm / (111.0 * math.Cos(lat*math.Pi/180))
if latDelta < 0.001 {
latDelta = 0.001
}
if lngDelta < 0.001 {
lngDelta = 0.001
}
rows, err := r.pool.Query(ctx, `
WITH bbox AS (
SELECT u.id, u.name, u.photo_url, ul.lat, ul.lng
FROM user_locations ul
JOIN users u ON u.id = ul.user_id
WHERE ul.visible = TRUE
AND u.is_blocked = FALSE
AND u.id != $3
AND ul.lat IS NOT NULL AND ul.lng IS NOT NULL
AND ul.updated_at > NOW() - INTERVAL '1 hour'
AND ul.lat BETWEEN $1 - $6 AND $1 + $6
AND ul.lng BETWEEN $2 - $7 AND $2 + $7
)
SELECT id, name, photo_url, lat, lng,
2 * 6371 * asin(sqrt(
sin(radians((lat - $1) / 2))^2 +
cos(radians($1)) * cos(radians(lat)) *
sin(radians((lng - $2) / 2))^2
)) * 1000 AS dist_m
FROM bbox
WHERE 2 * 6371 * asin(sqrt(
sin(radians((lat - $1) / 2))^2 +
cos(radians($1)) * cos(radians(lat)) *
sin(radians((lng - $2) / 2))^2
)) <= $4
ORDER BY dist_m
LIMIT $5`,
lat, lng, myID, radiusKm, limit, latDelta, lngDelta,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Nearby
for rows.Next() {
var n Nearby
var photo *string
if err := rows.Scan(&n.UserID, &n.Name, &photo, &n.Lat, &n.Lng, &n.DistanceM); err != nil {
return nil, err
}
if photo != nil {
n.PhotoURL = *photo
}
out = append(out, n)
}
return out, rows.Err()
}
var ErrNotFound = errors.New("not found")