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")