package locations import ( "context" "errors" "math" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type Location struct { UserID uuid.UUID Lat float64 Lng float64 Visible bool UpdatedAt time.Time } 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 часа 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 } rows, err := r.pool.Query(ctx, ` SELECT u.id, u.name, u.photo_url, ul.lat, ul.lng, 2 * 6371 * asin(sqrt( sin(radians((ul.lat - $1) / 2))^2 + cos(radians($1)) * cos(radians(ul.lat)) * sin(radians((ul.lng - $2) / 2))^2 )) AS dist_km 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 2 * 6371 * asin(sqrt( sin(radians((ul.lat - $1) / 2))^2 + cos(radians($1)) * cos(radians(ul.lat)) * sin(radians((ul.lng - $2) / 2))^2 )) <= $4 ORDER BY dist_km LIMIT $5`, lat, lng, myID, radiusKm, limit, ) 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 } n.DistanceM = n.DistanceM * 1000 out = append(out, n) } return out, rows.Err() } var ErrNotFound = errors.New("not found")