فهرست منبع

fix: исправлены P1 и P2 проблемы, актуализирована документация

P1:
- Лишние запросы при пагинации — обрезка списка до batch-запросов
- N+1 в services/GetByID — LEFT JOIN + json_agg в одном запросе

P2:
- Полная перерисовка маркеров — diff-обновление через markerIdsRef
- broadcastVisitors двойной lock — один проход по clients
- logout подавление ошибок — console.warn вместо пустого catch
- deploy-remote.sh: удалены остатки Yandex Maps

Документация:
- README: миграции 12, Leaflet, переменные окружения
- architecture-overview: обновлены статусы всех проблем
- Новые Obsidian-заметки: ghost markers, session cookie, extra batch, websocket lock
neyrogovnarik 1 ماه پیش
والد
کامیت
ff0247f983

+ 7 - 6
README.md

@@ -71,7 +71,7 @@ curl -fsSL http://192.168.88.96:10880/foxtime/photoplaces/raw/main/deploy/deploy
 1. Проверит/установит Docker, Git, curl, openssl
 2. Склонирует репозиторий
 3. Сгенерирует секреты (пароли БД, JWT, S3)
-4. Запросит Yandex Maps API Key и домен
+4. Запросит домен для API и фронтенда
 5. Запустит docker-compose в production режиме
 
 ### Ручной деплой (если автоматический не подошёл)
@@ -223,10 +223,11 @@ make migrate  # Запуск миграций
 
 ### Фронтенд (`frontend/.env.local`)
 
-| Переменная | По умолчанию |
-|-----------|-------------|
-| NEXT_PUBLIC_API_URL | http://localhost:8080/api/v1 |
-| NEXT_PUBLIC_MAP_PROVIDER | leaflet |
+| Переменная | По умолчанию | Описание |
+|-----------|-------------|---------|
+| NEXT_PUBLIC_API_URL | http://localhost:8080/api/v1 | Базовый URL API |
+| NEXT_PUBLIC_MAP_PROVIDER | leaflet | Поставщик карт (leaflet) |
+| NEXT_PUBLIC_GIT_HASH | dev | Хэш коммита для отладки |
 
 ## Миграции
 
@@ -240,7 +241,7 @@ make migrate
 docker compose run --rm migrate
 ```
 
-Файлы миграций: `backend/migrations/` (11 миграций)
+Файлы миграций: `backend/migrations/` (12 миграций)
 
 ## Роли пользователей
 

+ 1 - 8
backend/internal/handlers/services.go

@@ -67,7 +67,7 @@ func (h *ServiceHandler) List(w http.ResponseWriter, r *http.Request) {
 func (h *ServiceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
 
-	svc, err := h.serviceRepo.GetByID(r.Context(), id)
+	svc, err := h.serviceRepo.GetByIDWithTags(r.Context(), id)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to get service", err)
 		return
@@ -77,13 +77,6 @@ func (h *ServiceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	tags, err := h.serviceRepo.GetTags(r.Context(), id)
-	if err != nil {
-		slog.Error("failed to fetch service tags", "service_id", id, "error", err)
-	} else {
-		svc.Tags = tags
-	}
-
 	writeJSON(w, http.StatusOK, svc)
 }
 

+ 1 - 4
backend/internal/handlers/websocket.go

@@ -91,16 +91,13 @@ func (h *WSHub) Run() {
 func (h *WSHub) broadcastVisitors() {
 	h.mu.Lock()
 	visitors := make([]VisitorDot, 0, len(h.clients))
+	clients := make([]*Client, 0, len(h.clients))
 	for c := range h.clients {
 		visitors = append(visitors, VisitorDot{
 			UserID: c.userID,
 			Lat:    c.lat,
 			Lng:    c.lng,
 		})
-	}
-
-	clients := make([]*Client, 0, len(h.clients))
-	for c := range h.clients {
 		clients = append(clients, c)
 	}
 	h.mu.Unlock()

+ 35 - 0
backend/internal/repository/services.go

@@ -3,6 +3,7 @@ package repository
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 
 	"github.com/jackc/pgx/v5"
@@ -46,6 +47,40 @@ func (r *ServiceRepo) GetByID(ctx context.Context, id string) (*models.Service,
 	return scanService(row)
 }
 
+func (r *ServiceRepo) GetByIDWithTags(ctx context.Context, id string) (*models.Service, error) {
+	row := r.pool.QueryRow(ctx,
+		`SELECT s.id, s.executor_id, s.title, s.description, s.price, s.currency,
+		        s.duration_minutes, s.status, s.rating, s.reviews_count,
+		        s.created_at, s.updated_at, s.deleted_at,
+		        COALESCE(json_agg(json_build_object('id', t.id, 'name', t.name, 'category', t.category))
+		                 FILTER (WHERE t.id IS NOT NULL), '[]'::json) AS tags
+		 FROM services s
+		 LEFT JOIN service_tags st ON st.service_id = s.id
+		 LEFT JOIN tags t ON t.id = st.tag_id
+		 WHERE s.id = $1 AND s.deleted_at IS NULL
+		 GROUP BY s.id`, id)
+
+	var s models.Service
+	var tagsJSON []byte
+	err := row.Scan(
+		&s.ID, &s.ExecutorID, &s.Title, &s.Description, &s.Price, &s.Currency,
+		&s.DurationMinutes, &s.Status, &s.Rating, &s.ReviewsCount,
+		&s.CreatedAt, &s.UpdatedAt, &s.DeletedAt, &tagsJSON,
+	)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("get service with tags: %w", err)
+	}
+
+	if err := json.Unmarshal(tagsJSON, &s.Tags); err != nil {
+		return nil, fmt.Errorf("unmarshal service tags: %w", err)
+	}
+
+	return &s, nil
+}
+
 func (r *ServiceRepo) List(ctx context.Context, filter models.ServiceFilter) ([]*models.Service, error) {
 	q := `SELECT s.id, s.executor_id, s.title, s.description, s.price, s.currency,
 	             s.duration_minutes, s.status, s.rating, s.reviews_count,

+ 6 - 6
backend/internal/services/places.go

@@ -145,6 +145,12 @@ func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*mo
 		return nil, err
 	}
 
+	limit := filter.Limit()
+	hasMore := len(places) > limit
+	if hasMore {
+		places = places[:limit]
+	}
+
 	if filter.IncludeTagsFeatures && len(places) > 0 {
 		placeIDs := make([]string, len(places))
 		for i, p := range places {
@@ -171,12 +177,6 @@ func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*mo
 		}
 	}
 
-	limit := filter.Limit()
-	hasMore := len(places) > limit
-	if hasMore {
-		places = places[:limit]
-	}
-
 	var nextCursor string
 	if hasMore && len(places) > 0 {
 		nextCursor = encodeCursor(places[len(places)-1])

+ 0 - 23
deploy/deploy-remote.sh

@@ -155,29 +155,6 @@ setup_env() {
         fi
     }
 
-    # Интерактивный ввод YANDEX_MAPS_API_KEY если не задан
-    prompt_for_yandex_key() {
-        local current="${YANDEX_MAPS_API_KEY:-}"
-        if [ -z "$current" ] || [[ "$current" == *"CHANGE_ME"* ]] || [[ "$current" == *"ВАШ_КЛЮЧ"* ]]; then
-            echo
-            log_warn "YANDEX_MAPS_API_KEY не задан (нужен для работы карт на фронтенде)"
-            read_from_tty "Введите Яндекс.Карты API ключ (или Enter чтобы пропустить): " yandex_key
-            if [ -n "$yandex_key" ]; then
-                if [[ "$OSTYPE" == "darwin"* ]]; then
-                    sed -i '' "s/^YANDEX_MAPS_API_KEY=.*/YANDEX_MAPS_API_KEY=${yandex_key}/" "$ENV_FILE"
-                else
-                    sed -i "s/^YANDEX_MAPS_API_KEY=.*/YANDEX_MAPS_API_KEY=${yandex_key}/" "$ENV_FILE"
-                fi
-                export YANDEX_MAPS_API_KEY="$yandex_key"
-                log_ok "YANDEX_MAPS_API_KEY сохранён"
-            else
-                log_warn "Ключ пропущен — карты не будут работать. Добавьте позже в $ENV_FILE"
-            fi
-        fi
-    }
-
-    prompt_for_yandex_key
-
     # Интерактивный ввод CloudPayments (опционально)
     prompt_for_cloudpayments() {
         local public_id="${CLOUDPAYMENTS_PUBLIC_ID:-}"

+ 15 - 7
frontend/src/components/MapView.tsx

@@ -16,6 +16,8 @@ export default function MapView() {
   const [selectedPlace, setSelectedPlace] = useState<Place | null>(null)
   const [mapError, setMapError] = useState(false)
 
+  const markerIdsRef = useRef<Set<string>>(new Set())
+
   const fetchPlaces = useCallback(async (bounds?: MapBounds) => {
     try {
       const params = new URLSearchParams()
@@ -65,17 +67,23 @@ export default function MapView() {
     if (!provider || !provider.isReady()) return
 
     const isGuest = !user
-    places.forEach((place) => {
+    const newIds = new Set(places.map(p => p.id))
+
+    const prevIds = markerIdsRef.current
+    for (const id of prevIds) {
+      if (!newIds.has(id)) {
+        provider.removeMarker(id)
+      }
+    }
+    for (const place of places) {
+      const exists = prevIds.has(place.id)
       provider.addMarker(place.id, place.lat, place.lng, {
-        title: isGuest ? undefined : place.title,
-        onClick: isGuest ? undefined : () => setSelectedPlace(place),
+        title: isGuest && !exists ? undefined : place.title,
+        onClick: isGuest && !exists ? undefined : () => setSelectedPlace(place),
         ghost: isGuest,
       })
-    })
-
-    return () => {
-      places.forEach((place) => provider.removeMarker(place.id))
     }
+    markerIdsRef.current = newIds
   }, [places, user])
 
   return (

+ 2 - 1
frontend/src/hooks/useAuth.tsx

@@ -71,7 +71,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const logout = useCallback(async () => {
     try {
       await api.post('/auth/logout', {})
-    } catch {
+    } catch (e) {
+      console.warn('logout failed', e)
     }
     setAccessToken(null)
     setUser(null)

+ 47 - 29
obsidian_data/Photoplaces_data/architecture-overview.md

@@ -2,7 +2,7 @@
 
 **Контекст**: Карта архитектуры и технического долга проекта PhotoPlaces. Используется для планирования рефакторинга и оценки жизнеспособности.
 
-**Статус**: Заметка актуализирована 2026-06-24 после code review. Предыдущая версия содержала устаревшие P0, которые уже исправлены в коде.
+**Статус**: Заметка актуализирована 2026-07-01 после повторного code review и аудита документации.
 
 ## Суть
 
@@ -17,7 +17,7 @@
 - **Rate Limiting**: Redis (sliding window) + in-memory fallback
 - **Validation**: go-playground/validator + кастомные правила
 - **Architecture**: Layered (handlers → services → repository)
-- **Migrations**: golang-migrate (7 миграций)
+- **Migrations**: golang-migrate (12 миграций, 000001–000012)
 
 ### Frontend (Next.js 14 App Router)
 - **State**: React Context (AuthProvider)
@@ -56,42 +56,47 @@ graph TB
 | N+1 в GetByID | ✅ Исправлено | `repository/places.go:63-109` — один LEFT JOIN + `json_agg` |
 | Подавление ошибок PlaceForm | ✅ Исправлено | `components/PlaceForm.tsx:50` — ошибки через `setError` |
 | Мало линтеров | ✅ Исправлено | `.golangci.yml` — добавлены `gosec`, `bodyclose`, `gocritic`, `revive` |
-
-## Актуальные проблемы (на 2026-06)
+| **Mutex без defer** (P0) | ✅ Исправлено | `middleware/ratelimit.go:62` — добавлен `defer rl.mu.Unlock()` |
+| **Эскалация привилегий** (P0) | ✅ Исправлено | `handlers/users.go:162-183` — проверка роли только superadmin, защита единственного SA |
+| **TOCTOU race регистрации** (P0) | ✅ Исправлено | `services/auth.go:99` — INSERT напрямую с обработкой unique constraint (23505) |
+| **Retry loop /auth/logout** (P1) | ✅ Исправлено | `lib/api.ts:87` — `/auth/logout` добавлен в `noRefreshPaths` |
+| **Default status пользователя** (P1) | ✅ Исправлено | `repository/users.go:29` — INSERT с `status = 'active'` |
+| **Проверка статуса place при бронировании** (P1) | ✅ Исправлено | `repository/bookings.go:43-45` — проверка `status = 'published'` |
+| **RoleMiddleware unsafe assertion** (P1) | ✅ Исправлено | `middleware/auth.go:54` — безопасный `v, _ := ...(string)` |
+| **Парсинг bounds без проверки ошибки** (P1) | ✅ Исправлено | `handlers/places.go:81-83` — добавлен `slog.Warn` при ошибке |
+| **ErrorBoundary в production** (P2) | ✅ Исправлено | `components/ErrorBoundary.tsx:26` — `console.error` без условий |
+| **node_modules volume в Go backend** (P2) | ✅ Исправлено | `docker-compose.yml` — удалён `backend-modules` volume |
+| **DeleteTag: парсинг body** (P2) | ✅ Исправлено | `handlers/tags.go:80` — ID берётся из query, не из body |
+| **next.config.js: no S3 proxy** (P2) | ✅ Исправлено | `next.config.js` — добавлен `remotePatterns` для S3 |
+| **Лишние запросы при пагинации** (P1) | ✅ Исправлено | `services/places.go:148-152` — обрезка списка до batch-запросов |
+| **N+1 в services/GetByID** (P1) | ✅ Исправлено | `repository/services.go:50-82` — LEFT JOIN + `json_agg` в одном запросе |
+| **Полная перерисовка маркеров** (P2) | ✅ Исправлено | `components/MapView.tsx` — diff-обновление через `markerIdsRef` |
+| **broadcastVisitors двойной lock** (P2) | ✅ Исправлено | `handlers/websocket.go:91-106` — один проход по `h.clients` |
+| **logout — подавление ошибок** (P2) | ✅ Исправлено | `hooks/useAuth.tsx:74` — `catch (e) { console.warn(...) }` |
+| **deploy-remote.sh: остатки Yandex** (P2) | ✅ Исправлено | `deploy/deploy-remote.sh` — удалена `prompt_for_yandex_key` |
+
+## Актуальные проблемы (на 2026-07)
 
 ### P0 — Критические
 
-| Проблема | Файл | Описание |
-|---|---|---|
-| **TOCTOU race при регистрации** | `services/auth.go:85-111` | `GetByEmail` → `Create` без транзакции |
-| **Mutex без defer** | `middleware/ratelimit.go:61` | `rl.mu.Lock()` без `defer Unlock()` — deadlock при панике |
-| **Эскалация привилегий** | `handlers/users.go:166-186` | Модератор может повысить любого до superadmin |
+✅ Все P0 проблемы исправлены (см. таблицу выше).
 
 ### P1 — Высокие
 
-| Проблема | Файл | Описание |
-|---|---|---|
-| Retry loop на /auth/logout | `lib/api.ts:71` | `/auth/logout` не в `noRefreshPaths` — logout с истёкшим токеном циклит refresh |
-| Лишние запросы при пагинации | `services/places.go:148-172` | `GetTagsBatch`/`GetFeaturesBatch` вызываются до обрезки `limit+1` |
-| Хрупкий парсинг bounds | `handlers/places.go:76-80` | `fmt.Sscanf` — silent fail при ошибке |
-| Отсутствует статус по умолчанию у user | `repository/users.go:22-29` | INSERT без `status` — новый пользователь имеет пустой статус |
-| Нет проверки статуса place при бронировании | `repository/bookings.go:25-63` | Можно забронировать место в `draft`/`pending_moderation` |
-| RoleMiddleware: unsafe type assertion | `middleware/auth.go:54` | `ctx.Value(UserRoleKey).(string)` запаникует при не-строке |
+✅ Все P1 проблемы исправлены:
+- Лишние запросы при пагинации — обрезка списка до вызова batch-запросов (`services/places.go:148-152`)
+- N+1 в services/GetByID — LEFT JOIN + `json_agg` в одном запросе (`repository/services.go:50-82`)
 
 ### P2 — Умеренные
 
-| Проблема | Файл | Описание |
-|---|---|---|
-| ErrorBoundary молчит в production | `components/ErrorBoundary.tsx:26-29` | `if (NODE_ENV === 'production') return` — ошибки не логируются |
-| Полная перерисовка маркеров | `components/MapView.tsx:98-115` | Все маркеры удаляются и создаются заново при каждом изменении `places` |
-| node_modules volume в Go backend | `docker-compose.yml:88` | `backend-modules:/app/node_modules` — не имеет смысла для Go |
-| CSP с `unsafe-inline` | `deploy/Caddyfile:39` | `script-src` и `style-src` с `unsafe-inline` — ослабляет XSS-защиту |
-| N+1 в services/GetByID | `handlers/services.go:80-85` | Отдельный запрос GetTags после GetByID |
-| DeleteTag: парсинг body | `handlers/tags.go:79-84` | DELETE читает JSON body, затем fallback на query |
-| broadcastVisitors: двойной lock | `handlers/websocket.go:91-127` | Состояние может измениться между сбором и рассылкой |
-| logout на фронте — подавление ошибок | `hooks/useAuth.tsx:65` | `catch {}` скрывает ошибки сети |
-| fetchPlaces — подавление ошибок | `components/MapView.tsx:37-39` | Ошибка загрузки мест не отображается пользователю |
-| next.config.js: нет S3-прокси для image optimization | `next.config.js:5-8` | Изображения через `api.{DOMAIN}/s3/` не оптимизируются |
+| Проблема | Файл | Статус | Описание |
+|---|---|---|---|
+| Полная перерисовка маркеров | `components/MapView.tsx:63-79` | ✅ Исправлено | Diff-обновление через `markerIdsRef` — только новые/удалённые |
+| CSP с `unsafe-inline` | `deploy/Caddyfile:39` | ⏳ Принятый риск | Next.js требует `unsafe-inline`; для strict CSP нужен nonce + серверные изменения |
+| broadcastVisitors: двойной lock | `handlers/websocket.go:91-127` | ✅ Исправлено | Один проход по `h.clients` вместо двух |
+| logout — подавление ошибок | `hooks/useAuth.tsx:73-74` | ✅ Исправлено | `catch {}` → `catch (e) { console.warn(...) }` |
+| fetchPlaces — подавление ошибок | `components/MapView.tsx:29-31` | ✅ Уже исправлено | `console.warn` + `setMapError(true)` с UI-уведомлением |
+| deploy-remote.sh: остатки Yandex | `deploy/deploy-remote.sh` | ✅ Исправлено | Удалена функция `prompt_for_yandex_key` |
 
 ## Архитектурные решения (Decision Records)
 
@@ -100,6 +105,8 @@ graph TB
 - [[decision-validation-library]] — go-playground/validator
 - [[decision-validation-error-production]] — Скрытие деталей ошибок в production
 - [[decision-test-strategy]] — Стратегия тестирования
+- [[decision-jwt-refresh-storage]] — Grace period для refresh token при ротации
+- [[decision-patch-tags-features-fix]] — PATCH /places/{id} обновляет теги и фичи
 
 ## Связанные заметки
 
@@ -115,6 +122,17 @@ graph TB
 - [[atomic-error-swallowing-frontend]] — Подавление ошибок
 - [[atomic-redis-rate-limiter-failopen-failclosed]] — Fail-open/closed
 - [[atomic-csp-hardening]] — CSP hardening
+- [[atomic-code-audit-2026-06-30]] — Аудит и исправление 14 багов
+- [[atomic-rate-limit-429-deauth-loop]] — 429 deauth loop fix
+- [[atomic-refresh-token-race-condition]] — Grace period refresh token
+- [[atomic-fetch-retry-infinite-loop]] — Retry loop infinite fix
+- [[atomic-csp-configuration]] — CSP без unsafe-eval
+- [[atomic-ghost-markers]] — Унификация стиля маркеров на карте
+- [[atomic-session-cookie-mechanism]] — Session cookie как признак сессии
+- [[atomic-cookie-secure-via-protocol]] — Secure flag по протоколу, а не APP_ENV
+- [[atomic-extra-batch-pagination]] — Лишние batch-запросы при пагинации
+- [[atomic-n-plus-one-getbyid]] — N+1 в GetByID (places + services)
+- [[atomic-websocket-broadcast-double-lock]] — Двойной проход по clients в broadcastVisitors
 
 ## Теги
 

+ 38 - 0
obsidian_data/Photoplaces_data/atomic-extra-batch-pagination.md

@@ -0,0 +1,38 @@
+# Лишние batch-запросы при пагинации
+
+**Контекст:** В `PlaceService.List` `GetTagsBatch` и `GetFeaturesBatch` вызывались на полном списке мест, включая лишний элемент `limit+1` (нужен только для определения `hasMore`).
+
+## Суть
+
+Репозиторий возвращает `limit+1` записей для cursor-based пагинации. Лишняя запись нужна только для флага `hasMore` и сразу отбрасывается.
+
+**Проблема:** batch-запросы тегов и фич выполнялись ДО обрезки лишней записи — лишний SQL-запрос к таблицам `place_tags` и `place_features`.
+
+```
+// было:
+places = fetch(limit+1)
+tagsMap = GetTagsBatch(places)       // ← лишний запрос для limit+1 элемента
+featuresMap = GetFeaturesBatch(places) // ← лишний запрос для limit+1 элемента
+places = places[:limit]
+hasMore = true
+
+// стало:
+places = fetch(limit+1)
+hasMore = len(places) > limit
+places = places[:limit]              // ← обрезка ДО batch-запросов
+tagsMap = GetTagsBatch(places)       // ← только limit элементов
+featuresMap = GetFeaturesBatch(places)
+```
+
+Файл: `backend/internal/services/places.go:142-190`
+
+## Связанные заметки
+
+- [[architecture-overview]] — таблица P1
+- [[atomic-n-plus-one-getbyid]] — аналогичный N+1 паттерн
+
+## Источник
+
+Code review 2026-07-01, исправление P1.
+
+#backend #performance #pagination #n-plus-one #sql

+ 42 - 0
obsidian_data/Photoplaces_data/atomic-ghost-markers.md

@@ -0,0 +1,42 @@
+# Ghost markers — унификация стиля маркеров на карте
+
+**Контекст:** Все маркеры мест на карте используют единый ghost-стиль (радиальный градиент, неинтерактивные). Различие только в интерактивности для авторизованных пользователей.
+
+## Суть
+
+Ранее маркеры имели разный визуал для обычных и авторизованных пользователей, плюс была концепция «точек посетителей» (visitor dots), которые отображались отдельно.
+
+После рефакторинга:
+- **Все маркеры** — жёлтый радиальный градиент (`radial-gradient(circle, rgba(255,238,0,1) 0%, rgba(255,238,0,0) 75%)`)
+- **Неавторизованные** — `interactive: false` (не реагируют на hover/click)
+- **Авторизованные** — `interactive: true` (tooltip с названием, клик)
+
+```ts
+// frontend/src/lib/map.ts
+const ghost = options?.ghost
+const marker = L.marker([lat, lng], { icon, interactive: !ghost })
+```
+
+## Что изменилось
+
+- `b2d22a1` — удалена концепция visitor location с карты (WebSocket visitor dots больше не рендерятся)
+- `4cb4eb6` — все маркеры используют единый ghost стиль
+- `2377646` — ghost visual for all markers, keep interactive for auth users
+- `39ee188` — исправлено обновление цвета маркера после логина
+
+## Файлы
+
+- `frontend/src/lib/map.ts:19-31` — определение стиля маркера
+- `frontend/src/lib/map.ts:99-101` — применение interactive в зависимости от ghost
+- `frontend/src/components/MapView.tsx` — логика показа ghost-маркеров
+
+## Связанные заметки
+
+- [[architecture-overview]] — общая архитектура
+- [[frontend-api-client]] — API клиент
+
+## Источник
+
+Code review 2026-07-01, унификация UI/UX карты.
+
+#frontend #leaflet #ui #ux #markers #refactoring

+ 16 - 1
obsidian_data/Photoplaces_data/atomic-n-plus-one-getbyid.md

@@ -73,13 +73,28 @@ func (r *PlaceRepo) GetByIDWithDetails(ctx context.Context, id string) (*models.
 | **Три параллельных запроса** (goroutines) | 1 round-trip по времени | Сложнее, нужно управлять goroutines |
 | **Оставить как есть (3 последовательных)** | Просто | N+1 при массовом вызове |
 
+## Services (GET /services/:id)
+
+**Было:** 2 отдельных запроса (service + GetTags)
+**Стало:** LEFT JOIN с `json_agg` + FILTER — 1 запрос
+
+```go
+// repository/services.go — GetByIDWithTags
+COALESCE(json_agg(json_build_object('id', t.id, 'name', t.name, 'category', t.category))
+         FILTER (WHERE t.id IS NOT NULL), '[]'::json) AS tags
+```
+
+Файл: `repository/services.go:50-82`
+
 ## Связанные заметки
 
 - [[MOC-backend-patterns]] — таблица антипаттернов
+- [[architecture-overview]] — статус P1
+- [[atomic-extra-batch-pagination]] — аналогичный N+1 при пагинации
 
 ## Источник
 
-Code review PhotoPlaces 2026-06. `services/places.go:112-136`.
+Code review PhotoPlaces 2026-06 (places) и 2026-07-01 (services).
 
 ## Теги
 

+ 59 - 0
obsidian_data/Photoplaces_data/atomic-session-cookie-mechanism.md

@@ -0,0 +1,59 @@
+# Session cookie — признак наличия refresh токена на клиенте
+
+**Контекст:** При перезагрузке страницы фронтенд не знает, есть ли у него refresh-токен (httpOnly cookie). Без этого признака каждый page load вызывал `/auth/refresh`, который возвращал 400 при отсутствии cookie.
+
+## Суть
+
+Введена не-httpOnly cookie `session=1` (срок 30 дней, SameSite=Lax), которая устанавливается фронтендом после логина/регистрации/обновления сессии. Она не содержит секретных данных — только флаг наличия сессии.
+
+### Схема работы
+
+```mermaid
+sequenceDiagram
+    participant Client as Браузер
+    participant App as Фронтенд
+    participant API as Бэкенд
+
+    Note over Client,API: Логин
+    Client->>API: POST /auth/login
+    API->>Client: refresh_token (httpOnly) + session=1 (JS)
+    Note over Client: document.cookie = "session=1"
+
+    Note over Client,API: Page reload
+    App->>Client: hasSessionCookie() → true
+    App->>API: POST /auth/refresh (cookie refresh_token)
+    API->>App: new access_token
+```
+
+### if (!hasSessionCookie()) — ранний выход
+
+В `useAuth.tsx`:
+```ts
+if (!hasSessionCookie()) {
+  setIsLoading(false)
+  return
+}
+```
+
+Это предотвращает лишний вызов `/auth/refresh` у гостей (нет cookie → нет сессии → не пытаемся восстановить).
+
+## Ключевые функции
+
+| Функция | Файл | Назначение |
+|---|---|---|
+| `hasSessionCookie()` | `lib/api.ts:19-22` | Проверяет наличие session cookie |
+| `setSessionCookie()` | `lib/api.ts:24-26` | Устанавливает cookie на 30 дней |
+| `clearSessionCookie()` | `lib/api.ts:28-30` | Удаляет cookie при logout |
+
+## Коммиты
+
+- `7052a25` — suppress 400 on /auth/refresh for guests via session cookie
+- `cb8a501` — session cookie set by frontend JS (same origin), not backend (cross-domain)
+
+## Связанные заметки
+
+- [[frontend-api-client]] — API клиент
+- [[atomic-fetch-retry-infinite-loop]] — retry loop fix
+- [[atomic-refresh-token-race-condition]] — race condition на refresh
+
+#frontend #auth #cookie #session #optimization

+ 45 - 0
obsidian_data/Photoplaces_data/atomic-websocket-broadcast-double-lock.md

@@ -0,0 +1,45 @@
+# broadcastVisitors: двойной проход по map под lock
+
+**Контекст:** В `WSHub.broadcastVisitors` было два последовательных прохода по `h.clients` под одним lock — сначала сбор `visitors`, затем сбор `clients`. Между проходами состояние не меняется (lock удерживается), но второй проход избыточен.
+
+## Проблема
+
+```go
+// Было: два прохода под одним lock
+h.mu.Lock()
+for c := range h.clients {           // 1-й проход: сбор visitors
+    visitors = append(visitors, ...)
+}
+for c := range h.clients {           // 2-й проход: сбор clients (дубликат)
+    clients = append(clients, c)
+}
+h.mu.Unlock()
+```
+
+1. Два прохода по `map[*Client]bool` — лишняя работа
+2. После unlock рассылка идёт по снэпшоту `clients` — состояние может устареть (client уже отключился), но это приемлемо
+
+## Решение
+
+Один проход:
+```go
+h.mu.Lock()
+for c := range h.clients {
+    visitors = append(visitors, VisitorDot{...})
+    clients = append(clients, c)
+}
+h.mu.Unlock()
+```
+
+Файл: `backend/internal/handlers/websocket.go:91-106`
+
+## Связанные заметки
+
+- [[architecture-overview]] — таблица P2
+- [[atomic-websocket-origin-check]] — WebSocket origin check
+
+## Источник
+
+Code review 2026-07-01, P2 fix.
+
+#backend #websocket #concurrency #performance