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
153 lines
4.2 KiB
Go
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")
|