Skip to content

Chat: Real-time мессенджер покупатель ↔ продавец

Scope

WebSocket-чат между покупателем и продавцом, привязанный к конкретному товару. Один диалог на пару (покупатель + товар). Реалтайм через Centrifugo, SharedWorker-синглтон между табами.

Ключевые решения

АспектВыборОбоснование
ТранспортCentrifugo (отдельный Go-сервис)Продукт Avito, масштабируемый, снимает нагрузку с PHP
Авторизация WSConnect Proxy (куки)Используем существующую сессию PARTIZAP_SESSION, без JWT
Формат чата1-to-1 по товаруКак у Avito: контекст товара в каждом диалоге
Типы сообщенийТекст + фотоДля запчастей важно: показать дефект, VIN, маркировку
РеалтаймOnline + typing + read receiptsСтандарт современных мессенджеров
УведомленияБейдж в хедере + звукBrowser Push — позже
Tab sharingSharedWorker (fallback: прямое подключение)1 WS-соединение на все табы
UIОднопанельный (как Avito)Мобильный паттерн, адаптированный для веба

Архитектура

Общая схема

┌─────────────┐     centrifuge-js      ┌─────────────────┐
│  Browser Tab │ ◄──── SharedWorker ──► │   Centrifugo     │
│  (Vue App)   │      (1 WS conn)      │   (Go server)    │
├─────────────┤                        ├─────────────────┤
│  Browser Tab │ ◄─── MessagePort ──┘   │  channels:       │
│  (Vue App)   │                        │  • personal:#uid │
└─────────────┘                        └────────┬────────┘
                                                │ Connect Proxy
                                       ┌────────▼────────┐
                                       │   PHP Backend    │
                                       │   (Slim 4)       │
                                       │  • REST API      │
                                       │  • publish()     │
                                       └─────────────────┘

Поток отправки сообщения

  1. Покупатель вводит текст → POST /api/vendor/conversations/:id/messages
  2. PHP сохраняет в БД → публикует в Centrifugo через Server API (personal:#sellerId и personal:#buyerId)
  3. Centrifugo доставляет обоим участникам через WS
  4. SharedWorker получает событие → рассылает всем табам через MessagePort

Поток подключения (Connect Proxy)

  1. Фронт вызывает connect() — куки пробрасываются автоматически
  2. Centrifugo проксирует запрос на PHP: POST /api/centrifugo/connect
  3. PHP валидирует сессию через PARTIZAP_SESSION куку
  4. PHP возвращает { result: { user: "42", channels: ["personal:#42"] } }
  5. Centrifugo устанавливает соединение + подписывает на personal:#42

Каналы Centrifugo

Один персональный канал на пользователя (паттерн Avito):

КаналНазначениеПодписка
personal:#userIdВсе события пользователяServer-side (при connect)

Никаких каналов chat:$convId — всё через персональный канал.

Модель данных

БД (бэкенд)

sql
conversations
├── id (PK)
├── product_id (FK → products.id)
├── buyer_id (FK → users.id)       -- тот, кто НЕ владелец товара
├── seller_id (FK → users.id)      -- владелец товара
├── buyer_unread_count (INT, default 0)
├── seller_unread_count (INT, default 0)
├── last_message_id (FK → messages.id, nullable)
├── created_at
└── updated_at

-- Индексы:
-- (buyer_id, updated_at DESC) — список диалогов покупателя
-- (seller_id, updated_at DESC) — список диалогов продавца
-- UNIQUE (product_id, buyer_id) — один диалог на пару

messages
├── id (PK)
├── conversation_id (FK → conversations.id)
├── sender_id (FK → users.id)
├── type ENUM('text', 'image', 'system')
├── text (TEXT, nullable)
├── image_url (VARCHAR, nullable)
├── image_thumbnail (VARCHAR, nullable)
├── status ENUM('sent', 'delivered', 'read')
└── created_at

-- Индексы:
-- (conversation_id, created_at DESC) — история сообщений

Zod-схемы (фронтенд)

typescript
// entities/message/model/message.schema.ts
const messageTypeSchema = z.enum(['text', 'image', 'system'])
const messageStatusSchema = z.enum(['sent', 'delivered', 'read'])

const messageSchema = z.object({
  id: z.number(),
  conversation_id: z.number(),
  sender_id: z.number(),
  type: messageTypeSchema,
  text: z.string().nullable(),
  image_url: z.string().nullable(),
  image_thumbnail: z.string().nullable(),
  status: messageStatusSchema,
  created_at: z.string(),
})

// entities/conversation/model/conversation.schema.ts
const conversationSchema = z.object({
  id: z.number(),
  product: z.object({
    id: z.number(),
    title: z.string(),
    price: z.number(),
    image_url: z.string().nullable(),
    status: productStatusSchema,
  }),
  companion: z.object({
    id: z.number(),
    display_name: z.string(),
    avatar_url: z.string().nullable(),
  }),
  last_message: messageSchema.nullable(),
  unread_count: z.number(),
  created_at: z.string(),
  updated_at: z.string(),
})

Centrifugo: события

Все события приходят в персональный канал personal:#userId:

message.new

json
{
  "type": "message.new",
  "data": {
    "conversation_id": 123,
    "message": { "id": 456, "sender_id": 7, "type": "text", "text": "...", "status": "sent", "created_at": "..." },
    "conversation_preview": {
      "product_title": "Фара левая BMW E46",
      "companion_name": "Иван"
    }
  }
}

typing

json
{
  "type": "typing",
  "data": { "conversation_id": 123, "user_id": 7 }
}

message.read

json
{
  "type": "message.read",
  "data": { "conversation_id": 123, "reader_id": 7, "last_read_message_id": 450 }
}

unread.update

json
{
  "type": "unread.update",
  "data": { "total_unread": 3 }
}

presence

json
{
  "type": "presence",
  "data": { "user_id": 7, "online": true, "last_seen_at": null }
}

Backend API контракт

Все эндпоинты следуют существующим конвенциям проекта.

Centrifugo (внутренний)

json
{
  "methods": ["POST"],
  "pattern": "/api/centrifugo/connect",
  "handler": "Centrifugo\\ConnectAction",
  "middleware": [],
  "description": {
    "en": "Centrifugo connect proxy. Validates session cookie, returns user info and channels. Called by Centrifugo, not by frontend",
    "ru": "Connect proxy для Centrifugo. Валидирует сессию через куки, возвращает user и каналы. Вызывается Centrifugo, не фронтом"
  }
}

Формат ответа (Centrifugo connect proxy protocol):

json
{
  "result": {
    "user": "42",
    "channels": ["personal:#42"]
  }
}

Диалоги

json
[
  {
    "methods": ["GET"],
    "pattern": "/api/vendor/conversations",
    "handler": "Vendor\\ListConversationsAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "List conversations with cursor pagination (?filter=unread|all)",
      "ru": "Список диалогов с cursor-пагинацией (?filter=unread|all)"
    }
  },
  {
    "methods": ["POST"],
    "pattern": "/api/vendor/conversations",
    "handler": "Vendor\\CreateConversationAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Create or find existing conversation ({product_id}). 200 if exists, 201 if created",
      "ru": "Создать или найти существующий диалог ({product_id}). 200 если есть, 201 если создан"
    }
  },
  {
    "methods": ["GET"],
    "pattern": "/api/vendor/conversations/{id:[0-9]+}",
    "handler": "Vendor\\GetConversationAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Get conversation details (companion, product preview)",
      "ru": "Детали диалога (собеседник, превью товара)"
    }
  },
  {
    "methods": ["DELETE"],
    "pattern": "/api/vendor/conversations/{id:[0-9]+}",
    "handler": "Vendor\\DeleteConversationAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Soft-delete conversation for current user",
      "ru": "Мягкое удаление диалога для текущего пользователя"
    }
  }
]

Сообщения

json
[
  {
    "methods": ["GET"],
    "pattern": "/api/vendor/conversations/{id:[0-9]+}/messages",
    "handler": "Vendor\\ListMessagesAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "List messages with cursor pagination (newest first)",
      "ru": "Список сообщений с cursor-пагинацией (от новых к старым)"
    }
  },
  {
    "methods": ["POST"],
    "pattern": "/api/vendor/conversations/{id:[0-9]+}/messages",
    "handler": "Vendor\\SendMessageAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Send text message ({type: 'text', text}). Publishes to Centrifugo",
      "ru": "Отправка текстового сообщения. Публикует в Centrifugo"
    }
  },
  {
    "methods": ["POST"],
    "pattern": "/api/vendor/conversations/{id:[0-9]+}/messages/image",
    "handler": "Vendor\\SendImageMessageAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Send image message (multipart, max 10MB). Publishes to Centrifugo",
      "ru": "Отправка фото-сообщения (multipart, макс. 10МБ). Публикует в Centrifugo"
    }
  }
]

Действия в чате

json
[
  {
    "methods": ["POST"],
    "pattern": "/api/vendor/conversations/{id:[0-9]+}/read",
    "handler": "Vendor\\MarkConversationReadAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Mark all messages as read. Resets unread, publishes message.read to companion",
      "ru": "Отметить прочитанным. Обнуляет unread, публикует message.read собеседнику"
    }
  },
  {
    "methods": ["POST"],
    "pattern": "/api/vendor/conversations/{id:[0-9]+}/typing",
    "handler": "Vendor\\SendTypingAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Send typing indicator via Centrifugo (not stored in DB)",
      "ru": "Индикатор набора текста через Centrifugo (не сохраняется в БД)"
    }
  },
  {
    "methods": ["GET"],
    "pattern": "/api/vendor/conversations/unread-count",
    "handler": "Vendor\\GetUnreadCountAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "Total unread messages count for header badge",
      "ru": "Общий счётчик непрочитанных для бейджа в хедере"
    }
  },
  {
    "methods": ["GET"],
    "pattern": "/api/vendor/users/{id:[0-9]+}/online",
    "handler": "Vendor\\GetUserOnlineAction",
    "middleware": ["AuthMiddleware"],
    "description": {
      "en": "User online status ({online, last_seen_at})",
      "ru": "Онлайн-статус пользователя ({online, last_seen_at})"
    }
  }
]

Формат ответов

GET  /conversations           → { data: Conversation[], meta: { has_more, next_cursor } }
GET  /conversations/:id       → { data: Conversation }
POST /conversations            → { data: Conversation }  (200 existing | 201 created)
GET  /.../messages             → { data: Message[], meta: { has_more, next_cursor } }
POST /.../messages             → { data: Message }
POST /.../messages/image       → { data: Message }
GET  /.../unread-count         → { data: { total_unread: number } }
GET  /users/:id/online         → { data: { online: boolean, last_seen_at: string | null } }

Логика SendMessageAction (пошагово)

  1. Валидация (text: 1–2000 символов)
  2. Проверка: текущий user — участник диалога
  3. INSERT INTO messages
  4. UPDATE conversations SET last_message_id, updated_at
  5. Инкремент unread_count собеседника
  6. Centrifugo Server API → publish в personal:#companionId (событие message.new)
  7. Centrifugo Server API → publish в personal:#senderId (синхронизация между табами)
  8. Response: { data: Message }

SharedWorker: синглтон WS между табами

Архитектура

┌─────────┐  MessagePort  ┌──────────────────┐  centrifuge-js  ┌───────────┐
│  Tab 1   │◄────────────► │                  │◄──────────────► │           │
├─────────┤               │  SharedWorker     │                │ Centrifugo│
│  Tab 2   │◄────────────► │  (chat.worker.ts) │                │  Server   │
├─────────┤               │                  │                │           │
│  Tab 3   │◄────────────► │  1 WS connection │                │           │
└─────────┘               └──────────────────┘                └───────────┘

Протокол Tab ↔ Worker

typescript
// Tab → Worker (команды)
type WorkerCommand =
  | { cmd: 'connect' }
  | { cmd: 'disconnect' }

// Worker → Tab (события)
type WorkerEvent =
  | { event: 'connected' }
  | { event: 'disconnected', reason: string }
  | { event: 'publication', channel: string, data: CentrifugoEvent }
  | { event: 'error', message: string }

Жизненный цикл

  1. Первый таб → создаёт SharedWorker → Worker подключается к Centrifugo (куки auto)
  2. Второй таб → подключается к существующему Worker (WS уже есть)
  3. Событие из Centrifugo → Worker рассылает всем портам (табам)
  4. Последний таб закрыт → Worker умирает → WS закрывается

Fallback (Safari Private)

typescript
const worker = typeof SharedWorker !== 'undefined'
  ? new SharedWorker('/workers/chat.worker.js')
  : null

if (!worker) {
  // Прямое подключение centrifuge-js в табе
  // + BroadcastChannel для синхронизации unread count
}

Frontend: FSD-структура

entities/
├── conversation/
│   ├── model/conversation.schema.ts
│   └── ui/
│       └── ConversationCard.vue        # Карточка диалога в списке
└── message/
    ├── model/message.schema.ts
    └── ui/
        ├── MessageBubble.vue           # Пузырь сообщения (text/image/system)
        └── MessageStatus.vue           # Галочки (sent/delivered/read)

features/
├── chat-connection/
│   ├── composables/useCentrifugo.ts    # Обёртка над SharedWorker
│   └── worker/chat.shared-worker.ts    # SharedWorker с centrifuge-js
├── chat-messaging/
│   ├── composables/useConversations.ts # Список, фильтры, реактивное обновление
│   ├── composables/useMessages.ts      # История, отправка, optimistic updates
│   ├── composables/useTyping.ts        # Typing indicator (дебаунс 3с, автосброс 4с)
│   └── composables/useChatNotifications.ts  # Звук + бейдж
├── chat-image/
│   └── composables/useChatImageUpload.ts
└── contact-seller/
    └── ui/ContactSellerButton.vue      # «Написать продавцу» на карточке товара

widgets/
└── chat-unread-badge/
    └── ui/ChatUnreadBadge.vue          # Бейдж в хедере (иконка + счётчик)

pages/
└── cabinet/
    └── messages/
        ├── index.vue                   # Список диалогов (на весь экран)
        └── [id].vue                    # Переписка (на весь экран, кнопка «назад»)

Ключевые composables

useCentrifugo():

typescript
{
  isConnected: Readonly<Ref<boolean>>,
  onEvent: (type: string, handler: (data) => void) => void,
  off: (type: string) => void,
  connect: () => Promise<void>,   // куки auto, без токена
  disconnect: () => void,
}

useConversations():

typescript
{
  conversations: Ref<Conversation[]>,
  isLoading, hasMore, error,
  loadMore: () => Promise<void>,
  refresh: () => Promise<void>,
  filter: Ref<'all' | 'unread'>,
  // Реактивно: message.new → перемещает диалог наверх, обновляет last_message и unread
}

useMessages(conversationId):

typescript
{
  messages: Ref<Message[]>,
  isLoading, hasMore, error,
  loadOlder: () => Promise<void>,
  send: (text: string) => Promise<void>,     // optimistic insert
  sendImage: (file: File) => Promise<void>,
  markRead: () => Promise<void>,
}

Optimistic updates

  1. Сообщение сразу в UI со status: 'sent' и временным id
  2. POST на сервер
  3. Успех → заменяем временный id на реальный
  4. Ошибка → иконка ошибки + кнопка «повторить»

User Flows

Flow 1: Покупатель пишет продавцу

Карточка товара → «Написать продавцу» → POST /vendor/conversations {product_id}
  ├─ 201 Created → redirect /cabinet/messages/:id (новый)
  └─ 200 OK → redirect /cabinet/messages/:id (существующий)
→ Открывается переписка → ввод → POST .../messages
→ Оптимистично в UI → бэкенд → Centrifugo → продавцу

Flow 2: Продавец получает сообщение

Любая страница → SharedWorker получает message.new
→ ChatUnreadBadge обновляет счётчик
→ Звук (если не в этом чате)
→ /cabinet/messages → диалог всплывает наверх
→ Клик → /cabinet/messages/:id → markRead() → message.read покупателю

Flow 3: Отправка фото

В чате → кнопка 📎 → выбор файла → локальный превью (blob)
→ POST .../messages/image (multipart)
→ Ответ: {image_url, image_thumbnail} → заменяем blob
→ Centrifugo → собеседнику

Flow 4: Первый заход (инициализация)

Авторизация → SharedWorker.connect() → Centrifugo → Connect Proxy → PHP
→ Подписка на personal:#userId
→ GET /vendor/conversations/unread-count → бейдж в хедере

Flow 5: Reconnect

Сеть упала → centrifuge-js: exponential backoff (1s, 2s, 4s, 8s, max 30s)
→ Сеть вернулась → reconnect → Centrifugo отдаёт пропущенные из кэша
→ Если кэш истёк → refresh() списка + текущего чата

Задачи

Backend (PHP Slim 4)

Приблизительные задачи — бэкендер прорабатывает детали реализации самостоятельно.

#ЗадачаПриоритетЗависимости
B1Инфраструктура Centrifugo: поднять Centrifugo (Docker), Redis engine, namespace personal с server-side subscriptions, allowed_origins, CORSВысокий
B2Таблицы БД: conversations + messages со всеми индексами, UNIQUE constraint (product_id, buyer_id)Высокий
B3ConnectAction (Centrifugo proxy): валидация сессии через куку, возврат { user, channels }ВысокийB1
B4CreateConversationAction: {product_id} → найти или создать. Нельзя писать самому себе. buyer = текущий user, seller = владелец товараВысокийB2
B5ListConversationsAction: cursor pagination, ?filter=unread|all. JOIN product + companion. Сортировка по updated_at DESCВысокийB2
B6GetConversationAction: детали с product preview и companion. Проверка участияСреднийB2
B7SendMessageAction: валидация → INSERT → UPDATE conversations → инкремент unread → publish в Centrifugo (обоим участникам)ВысокийB2, B1
B8SendImageMessageAction: multipart upload, генерация thumbnail → сохранение → publishСреднийB7
B9ListMessagesAction: cursor pagination (от новых к старым). Проверка участияВысокийB2
B10MarkConversationReadAction: обнулить unread → UPDATE messages status='read' → publish message.read собеседнику + unread.update текущемуВысокийB2, B1
B11SendTypingAction: только publish typing через Centrifugo (не хранится в БД)НизкийB1
B12GetUnreadCountAction: SUM(unread_count) по всем диалогам текущего userВысокийB2
B13DeleteConversationAction: soft delete (флаг deleted_by_buyer / deleted_by_seller)НизкийB2
B14GetUserOnlineAction: Centrifugo presence API или last_seen в RedisНизкийB1

Frontend (Nuxt/Vue)

#ЗадачаПриоритетЗависимости
F1Zod-схемы: entities/conversation, entities/messageВысокий
F2SharedWorker: chat.shared-worker.ts — centrifuge-js, протокол, fallbackВысокий
F3useCentrifugo(): composable-обёртка над SharedWorkerВысокийF2
F4useConversations(): список, cursor pagination, фильтр, реактивное обновлениеВысокийF1, F3
F5useMessages(convId): история, отправка, optimistic updates, markReadВысокийF1, F3
F6ConversationCard.vue: entity-компонент карточки диалогаВысокийF1
F7MessageBubble.vue: entity-компонент пузыря (text/image/system)ВысокийF1
F8Страница /cabinet/messages/index.vue: список диалогов, фильтры, infinite scrollВысокийF4, F6
F9Страница /cabinet/messages/[id].vue: переписка, input, скролл, шапка с товаромВысокийF5, F7
F10ContactSellerButton.vue: «Написать продавцу» → POST /conversations → redirectВысокий
F11ChatUnreadBadge.vue: бейдж в хедере, реактивно из CentrifugoВысокийF3
F12useTyping(): дебаунс 3с, автосброс 4сСреднийF3
F13useChatNotifications(): звук + document.hiddenСреднийF3
F14useChatImageUpload(): выбор файла, локальный превью, multipartСреднийF5
F15MessageStatus.vue: галочки (sent → delivered → read)СреднийF1
F16Навигация cabinet layout: пункт «Сообщения» + бейдж в sidebarВысокийF11
F17i18n: ключи локализации для чата (ru.json)Средний
F18Online-статус: «в сети» / «был(а) N минут назад» в шапке чатаНизкийF3

Порядок реализации

Фаза 1 — Фундамент (backend + frontend параллельно)
  Backend:  B1 → B2 → B3
  Frontend: F1 → F2 → F3

Фаза 2 — Базовый чат
  Backend:  B4, B5, B7, B9, B12
  Frontend: F4, F5, F6, F7 → F8, F9, F10, F11, F16

Фаза 3 — Полировка
  Backend:  B6, B8, B10, B11, B13, B14
  Frontend: F12, F13, F14, F15, F17, F18

Референсы