Преглед изворни кода

feat(frontend): WebSocket visitor dots with auto-reconnect

- useWebSocket hook: connects to /ws/visitors, sends geolocation,
  receives visitors list, auto-reconnect with exponential backoff
  (1s -> 30s max), periodic position refresh every 60s
- MapView.tsx: replaces polling setInterval with WS, renders
  all visitor markers from server broadcast
- types/index.ts: add VisitorDot interface
neyrogovnarik пре 1 месец
родитељ
комит
b2e440fc29
4 измењених фајлова са 139 додато и 22 уклоњено
  1. 6 2
      FINDINGS.md
  2. 13 20
      frontend/src/components/MapView.tsx
  3. 108 0
      frontend/src/hooks/useWebSocket.ts
  4. 12 0
      frontend/src/types/index.ts

+ 6 - 2
FINDINGS.md

@@ -16,7 +16,10 @@
 6. **JWT секреты без дефолтов в production** — config.go требует JWT_SECRET, JWT_REFRESH_SECRET, ALLOWED_ORIGINS в production; дефолты ("dev-secret", "localhost:3000") запрещены, приложение паникует при старте.
 6. **JWT секреты без дефолтов в production** — config.go требует JWT_SECRET, JWT_REFRESH_SECRET, ALLOWED_ORIGINS в production; дефолты ("dev-secret", "localhost:3000") запрещены, приложение паникует при старте.
 7. **Token Reuse Detection** — уже реализован: `GetRevoked` в репозитории, проверка в `RefreshSession`, отзыв всех токенов пользователя при обнаружении, обработка в хендлере.
 7. **Token Reuse Detection** — уже реализован: `GetRevoked` в репозитории, проверка в `RefreshSession`, отзыв всех токенов пользователя при обнаружении, обработка в хендлере.
 8. **Скрытие внутренних ошибок в production** — все хендлеры используют единый `writeError` с параметром `err` для логирования; в production 500 ошибки возвращают "internal server error" вместо деталей БД/логики.
 8. **Скрытие внутренних ошибок в production** — все хендлеры используют единый `writeError` с параметром `err` для логирования; в production 500 ошибки возвращают "internal server error" вместо деталей БД/логики.
-9. **WebSocket heartbeat + pub/sub** — рефакторинг: Hub с `register`/`unregister`/`broadcast` каналами и `Run()` горутиной; `Client` с `send chan`, `readPump`/`writePump` горутинами; ping/pong heartbeat (60s pong wait, 54s ping interval); typed events (`{"type":"visitors","data":{...}}`) вместо плоского JSON. Breaking change: старый формат `{"visitors":[...]}` больше не поддерживается.
+9. **WebSocket heartbeat + pub/sub** — рефакторинг бэкенда и интеграция во фронтенд:
+   - **Backend**: Hub с `register/unregister/broadcast`, `Run()` горутина; `Client` с `send chan`, `readPump`/`writePump` горутинами; ping/pong heartbeat (60s pong wait, 54s ping interval); typed events (`{"type":"visitors","data":{...}}`).
+   - **Frontend**: хук `useWebSocket` с auto-reconnect (exponential backoff 1→30s); `MapView` заменяет polling 60s на WS для отображения точек других посетителей.
+   - Breaking change: старый формат `{"visitors":[...]}` не поддерживается.
 
 
 ### ✅ P1 — Исправлено
 ### ✅ P1 — Исправлено
 10. **Теги и фичи в листинге мест** — `PlaceFilter.IncludeTagsFeatures` + batch-загрузка (`GetTagsBatch`/`GetFeaturesBatch`), запрос `?include=tags,features`. N+1 проблема решена.
 10. **Теги и фичи в листинге мест** — `PlaceFilter.IncludeTagsFeatures` + batch-загрузка (`GetTagsBatch`/`GetFeaturesBatch`), запрос `?include=tags,features`. N+1 проблема решена.
@@ -80,7 +83,8 @@
 | `frontend/src/components/ErrorBoundary.tsx` | Новый компонент |
 | `frontend/src/components/ErrorBoundary.tsx` | Новый компонент |
 | `frontend/src/app/layout.tsx` | ErrorBoundary обёртка |
 | `frontend/src/app/layout.tsx` | ErrorBoundary обёртка |
 | `frontend/src/app/admin/tags/page.tsx` | Обработка ошибок |
 | `frontend/src/app/admin/tags/page.tsx` | Обработка ошибок |
-| `frontend/src/components/MapView.tsx` | console.error → тихий catch |
+| `frontend/src/components/MapView.tsx` | console.error → тихий catch; polling → WebSocket visitor dots |
+| `frontend/src/hooks/useWebSocket.ts` | **Новый**: хук с auto-reconnect, отправкой позиции, приёмом списка посетителей |
 | `frontend/vitest.config.ts` | Новый файл |
 | `frontend/vitest.config.ts` | Новый файл |
 | `frontend/src/test/` | setup.ts, ErrorBoundary.test.tsx |
 | `frontend/src/test/` | setup.ts, ErrorBoundary.test.tsx |
 | `obsidian_data/Photoplaces_data/` | 7 новых Zettelkasten-заметок |
 | `obsidian_data/Photoplaces_data/` | 7 новых Zettelkasten-заметок |

+ 13 - 20
frontend/src/components/MapView.tsx

@@ -2,17 +2,16 @@
 
 
 import { useEffect, useRef, useState, useCallback } from 'react'
 import { useEffect, useRef, useState, useCallback } from 'react'
 
 
-/** Константа-идентификатор маркера посетителя */
-const VISITOR_MARKER_ID = 'visitor-me'
 import { createYandexMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
 import { createYandexMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
 import { useAuth } from '@/hooks/useAuth'
 import { useAuth } from '@/hooks/useAuth'
+import { useWebSocket } from '@/hooks/useWebSocket'
 import type { Place } from '@/types'
 import type { Place } from '@/types'
 import { api } from '@/lib/api'
 import { api } from '@/lib/api'
 
 
 /**
 /**
  * Основной компонент карты. Инициализирует Яндекс.Карту, загружает места,
  * Основной компонент карты. Инициализирует Яндекс.Карту, загружает места,
  * отображает маркеры и показывает карточку места при клике.
  * отображает маркеры и показывает карточку места при клике.
- * Для неавторизованных пользователей каждую минуту обновляет геолокацию.
+ * Для неавторизованных пользователей через WebSocket показывает точки других посетителей на карте.
  */
  */
 export default function MapView() {
 export default function MapView() {
   const containerRef = useRef<HTMLDivElement>(null)
   const containerRef = useRef<HTMLDivElement>(null)
@@ -84,26 +83,20 @@ export default function MapView() {
     }
     }
   }, [places])
   }, [places])
 
 
-  /** Добавляет/обновляет маркер геолокации посетителя. Удаляет старый перед созданием нового. */
-  const handleAddVisitorDot = useCallback(() => {
-    if (!mapRef.current || !navigator.geolocation) return
-    navigator.geolocation.getCurrentPosition((pos) => {
-      const provider = mapRef.current
-      if (!provider) return
-      provider.removeMarker(VISITOR_MARKER_ID)
-      provider.addMarker(VISITOR_MARKER_ID, pos.coords.latitude, pos.coords.longitude, {
-        type: 'visitor',
-      })
-    })
-  }, [])
+  const { visitors } = useWebSocket(!user)
 
 
   useEffect(() => {
   useEffect(() => {
-    if (!user) {
-      handleAddVisitorDot()
-      const interval = setInterval(handleAddVisitorDot, 60000)
-      return () => clearInterval(interval)
+    if (!mapRef.current) return
+    const provider = mapRef.current
+
+    visitors.forEach((v, i) => {
+      provider.addMarker(`ws-visitor-${i}`, v.lat, v.lng, { type: 'visitor' })
+    })
+
+    return () => {
+      visitors.forEach((_, i) => provider.removeMarker(`ws-visitor-${i}`))
     }
     }
-  }, [user, handleAddVisitorDot])
+  }, [visitors])
 
 
   return (
   return (
     <div className="relative h-full w-full">
     <div className="relative h-full w-full">

+ 108 - 0
frontend/src/hooks/useWebSocket.ts

@@ -0,0 +1,108 @@
+'use client'
+
+import { useEffect, useRef, useState, useCallback } from 'react'
+import type { VisitorDot } from '@/types'
+
+interface WSMessage {
+  type: string
+  data: { visitors?: VisitorDot[] }
+}
+
+const INITIAL_RECONNECT_DELAY = 1000
+const MAX_RECONNECT_DELAY = 30000
+
+export function useWebSocket(enabled: boolean) {
+  const [visitors, setVisitors] = useState<VisitorDot[]>([])
+  const [connected, setConnected] = useState(false)
+  const wsRef = useRef<WebSocket | null>(null)
+  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>()
+  const reconnectAttemptRef = useRef(0)
+  const enabledRef = useRef(enabled)
+  enabledRef.current = enabled
+
+  const sendPosition = useCallback(() => {
+    if (!navigator.geolocation) return
+
+    navigator.geolocation.getCurrentPosition(
+      (pos) => {
+        const ws = wsRef.current
+        if (ws && ws.readyState === WebSocket.OPEN) {
+          ws.send(JSON.stringify({
+            type: 'visitor_update',
+            data: { lat: pos.coords.latitude, lng: pos.coords.longitude },
+          }))
+        }
+      },
+      () => {},
+      { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 },
+    )
+  }, [])
+
+  useEffect(() => {
+    if (!enabled) return
+
+    const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1'
+    const wsUrl = apiUrl.replace(/^http/, 'ws') + '/ws/visitors'
+
+    function connect() {
+      if (!enabledRef.current) return
+
+      const ws = new WebSocket(wsUrl)
+      wsRef.current = ws
+
+      ws.onopen = () => {
+        setConnected(true)
+        reconnectAttemptRef.current = 0
+        sendPosition()
+      }
+
+      ws.onmessage = (event) => {
+        try {
+          const msg: WSMessage = JSON.parse(event.data)
+          if (msg.type === 'visitors' && msg.data?.visitors) {
+            setVisitors(msg.data.visitors)
+          }
+        } catch { /* ignore malformed */ }
+      }
+
+      ws.onclose = () => {
+        setConnected(false)
+        wsRef.current = null
+        scheduleReconnect()
+      }
+
+      ws.onerror = () => {
+        ws.close()
+      }
+    }
+
+    function scheduleReconnect() {
+      if (!enabledRef.current) return
+      const delay = Math.min(
+        INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttemptRef.current),
+        MAX_RECONNECT_DELAY,
+      )
+      reconnectAttemptRef.current++
+      reconnectTimerRef.current = setTimeout(connect, delay)
+    }
+
+    connect()
+
+    return () => {
+      clearTimeout(reconnectTimerRef.current)
+      if (wsRef.current) {
+        wsRef.current.onclose = null
+        wsRef.current.close()
+        wsRef.current = null
+      }
+    }
+  }, [enabled, sendPosition])
+
+  useEffect(() => {
+    if (!connected) return
+    const interval = setInterval(sendPosition, 60000)
+    return () => clearInterval(interval)
+  }, [connected, sendPosition])
+
+  return { visitors, connected }
+}

+ 12 - 0
frontend/src/types/index.ts

@@ -223,6 +223,18 @@ export interface PaginatedResponse<T> {
   has_more: boolean
   has_more: boolean
 }
 }
 
 
+/**
+ * Точка посетителя на карте (приходит с WebSocket)
+ * @property user_id - ID авторизованного пользователя (опционально)
+ * @property lat - Широта
+ * @property lng - Долгота
+ */
+export interface VisitorDot {
+  user_id?: string
+  lat: number
+  lng: number
+}
+
 /**
 /**
  * Структура ошибки API (соответствует RFC 7807 Problem Details)
  * Структура ошибки API (соответствует RFC 7807 Problem Details)
  * @property type - URI типа ошибки
  * @property type - URI типа ошибки