Личные диалоги (P2P)
Перед началом: seq и идемпотентная отправка (Модуль 3). Инструменты: PostgreSQL (LATERAL), Go. Трудоёмкость: ~5 ч.
🔥 Инцидент: «У вас новый диалог — с пустотой»
Пользователь случайно кликнул по чужому аватару — и у собеседника в списке
появился диалог с пустой строкой и без имени: «диалог» без сообщений, с
заголовком ''. Пара дней — и в списке живёт десяток пустых «переписок»
из случайных кликов. Вторая жалоба из саппорта: «В списке написано
direct, что это?» — оказалось, кто-то вывел conversation.title для
личного диалога напрямую. В этом модуле собираем P2P-опыт: список
диалогов, который не врёт, и создание переписки, которое не создаёт мусор.
📖 Теория
4.1 Жизненный цикл личного диалога
Решаем продуктовую механику и фиксируем её явно:
- диалог создаётся осознанным действием («Написать») из контактов;
- пустой диалог (без сообщений) скрыт из списка до первого сообщения;
- заголовок direct не хранится: он собирается из имени собеседника на чтении. Имя изменилось — заголовки обновились у всех сразу. Хранить копию значило бы чинить её при каждом переименовании.
Правило «не хранить производное» — тот же принцип, что и last_seq vs
count(messages): производное либо вычисляется, либо честно
денормализуется с механикой поддержки.
4.2 Список диалогов одним запросом
Классическая задача «N+1 по последнему сообщению и собеседнику» решается
одним SQL — через LATERAL:
sqlSELECT c.id, c.type, c.title, c.last_seq, p.last_read_seq, c.last_seq - p.last_read_seq AS unread, m.text AS last_text, m.created_at AS last_at, peer.user_id AS peer_id, u.display_name AS peer_name FROM conversation_participants p JOIN conversations c ON c.id = p.conversation_id LEFT JOIN LATERAL ( SELECT text, created_at, sender_id FROM messages WHERE conversation_id = c.id ORDER BY seq DESC LIMIT 1 ) m ON true LEFT JOIN conversation_participants peer ON peer.conversation_id = c.id AND peer.user_id <> $1 LEFT JOIN users u ON u.id = peer.user_id WHERE p.user_id = $1 AND EXISTS (SELECT 1 FROM messages WHERE conversation_id = c.id) ORDER BY c.last_message_at DESC NULLS LAST;
Три вещи, за которые стоит зацепиться глазами:
LATERALвыполняет подзапрос «последнее сообщение» на каждый диалог, но это один поход в БД с индексом(conversation_id, seq DESC)— не N+1 из приложения;EXISTS (messages)прячет пустые диалоги (§4.1);ORDER BY last_message_at DESC NULLS LAST— активные наверху.
4.3 DTO для списка, а не «модель как есть»
Список диалогов — не Conversation из БД, а проекция под экран:
последнее сообщение, непрочитанные, собеседник у direct. Это осознанный
DTO: меняется интерфейс списка — меняется один SQL и один тип:
gotype ConversationListItem struct { ID uuid.UUID Type string Title string // group: из БД; direct: display_name собеседника Unread int64 LastText *string LastAt *time.Time Peer *UserCard }
4.4 Контакты
Для «Написать» нужен список пользователей. Честный минимум для курса:
пагинированный GET /api/v1/contacts?search= по display_name (ILIKE +
индекс pg_trgm — врезка; на учебных объёмах достаточно LIKE).
Полноценные «контакты с рукопожатием» — продуктовая тема за рамками курса.
💻 Практика
-
Хендлер
POST /api/v1/conversationsсtype=directдоводим до продуктового вида: тело{"type":"direct","peer_id":"…"}, ответ — DTO диалога (id + peer). Повторный вызов — тот же диалог (проверьте тестом идемпотентности из Модуля 2). -
GET /api/v1/conversations— по SQL из §4.2. СоберитеConversationListItemи отдайте:
jsonc{ "conversations": [{ "id": "…", "type": "direct", "title": "Bob", "unread": 3, "last_text": "…", "last_at": "2026-10-29T…", "peer": { "id": "…", "display_name": "Bob", "email": "bob@example.dev" } }] }
-
GET /api/v1/contacts?search=bo— пользователи для старта переписки, исключая себя. Обновите openapi,make validate-api. -
Путь P2P целиком (можно curl-ом, до фронтенда из Модуля 8): Алиса вызывает
directс Бобом → отправляет сообщение → Боб видит диалог в списке с непрочитанным 1 → отмечает read → непрочитанное 0. -
Тесты:
- чужой диалог не виден: список Боба не содержит диалог Алисы с Кэрол;
- пустой диалог скрыт; после первого сообщения появляется;
unreadна списке совпадает сlast_seq - last_read_seqпозиции.
Чек-лист модуля: заголовок direct собирается из имени собеседника; пустые диалоги не засоряют список; непрочитанные корректны; контакты есть; чужая переписка недоступна.
🧩 Quiz
LEFT JOIN LATERAL (... ORDER BY seq DESC LIMIT 1) в списке диалогов?last_seq - last_read_seq, а не подсчётом сообщений?✅ Итоги модуля
- Жизненный цикл direct зафиксирован: создание действием, пустые скрыты, заголовок вычисляется.
- Список диалогов — один SQL с LATERAL; непрочитанные — разность seq.
- DTO списка отделён от доменной модели — экран меняется независимо от схемы.
- Контакты дают точку входа в переписку; чужие диалоги недоступны by design.
🔗 Мостик к Модулю 5
Личная переписка работает. Следующий шаг — группы: несколько участников, роли с правами (кто может добавлять и удалять), системные сообщения («Алиса добавила Боба») и приглашения по ссылке. И заодно наведём порядок в правах: у группы, в отличие от личного диалога, «просто участник» — не единственная роль.