Appearance
Chat: Real-time мессенджер покупатель ↔ продавец
Scope
WebSocket-чат между покупателем и продавцом, привязанный к конкретному товару. Один диалог на пару (покупатель + товар). Реалтайм через Centrifugo, SharedWorker-синглтон между табами.
Ключевые решения
| Аспект | Выбор | Обоснование |
|---|---|---|
| Транспорт | Centrifugo (отдельный Go-сервис) | Продукт Avito, масштабируемый, снимает нагрузку с PHP |
| Авторизация WS | Connect Proxy (куки) | Используем существующую сессию PARTIZAP_SESSION, без JWT |
| Формат чата | 1-to-1 по товару | Как у Avito: контекст товара в каждом диалоге |
| Типы сообщений | Текст + фото | Для запчастей важно: показать дефект, VIN, маркировку |
| Реалтайм | Online + typing + read receipts | Стандарт современных мессенджеров |
| Уведомления | Бейдж в хедере + звук | Browser Push — позже |
| Tab sharing | SharedWorker (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() │
└─────────────────┘Поток отправки сообщения
- Покупатель вводит текст →
POST /api/vendor/conversations/:id/messages - PHP сохраняет в БД → публикует в Centrifugo через Server API (
personal:#sellerIdиpersonal:#buyerId) - Centrifugo доставляет обоим участникам через WS
- SharedWorker получает событие → рассылает всем табам через MessagePort
Поток подключения (Connect Proxy)
- Фронт вызывает
connect()— куки пробрасываются автоматически - Centrifugo проксирует запрос на PHP:
POST /api/centrifugo/connect - PHP валидирует сессию через
PARTIZAP_SESSIONкуку - PHP возвращает
{ result: { user: "42", channels: ["personal:#42"] } } - 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 (пошагово)
- Валидация (text: 1–2000 символов)
- Проверка: текущий user — участник диалога
INSERT INTO messagesUPDATE conversations SET last_message_id, updated_at- Инкремент
unread_countсобеседника - Centrifugo Server API →
publishвpersonal:#companionId(событиеmessage.new) - Centrifugo Server API →
publishвpersonal:#senderId(синхронизация между табами) - 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 }Жизненный цикл
- Первый таб → создаёт SharedWorker → Worker подключается к Centrifugo (куки auto)
- Второй таб → подключается к существующему Worker (WS уже есть)
- Событие из Centrifugo → Worker рассылает всем портам (табам)
- Последний таб закрыт → 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
- Сообщение сразу в UI со
status: 'sent'и временнымid - POST на сервер
- Успех → заменяем временный
idна реальный - Ошибка → иконка ошибки + кнопка «повторить»
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) | Высокий | — |
| B3 | ConnectAction (Centrifugo proxy): валидация сессии через куку, возврат { user, channels } | Высокий | B1 |
| B4 | CreateConversationAction: {product_id} → найти или создать. Нельзя писать самому себе. buyer = текущий user, seller = владелец товара | Высокий | B2 |
| B5 | ListConversationsAction: cursor pagination, ?filter=unread|all. JOIN product + companion. Сортировка по updated_at DESC | Высокий | B2 |
| B6 | GetConversationAction: детали с product preview и companion. Проверка участия | Средний | B2 |
| B7 | SendMessageAction: валидация → INSERT → UPDATE conversations → инкремент unread → publish в Centrifugo (обоим участникам) | Высокий | B2, B1 |
| B8 | SendImageMessageAction: multipart upload, генерация thumbnail → сохранение → publish | Средний | B7 |
| B9 | ListMessagesAction: cursor pagination (от новых к старым). Проверка участия | Высокий | B2 |
| B10 | MarkConversationReadAction: обнулить unread → UPDATE messages status='read' → publish message.read собеседнику + unread.update текущему | Высокий | B2, B1 |
| B11 | SendTypingAction: только publish typing через Centrifugo (не хранится в БД) | Низкий | B1 |
| B12 | GetUnreadCountAction: SUM(unread_count) по всем диалогам текущего user | Высокий | B2 |
| B13 | DeleteConversationAction: soft delete (флаг deleted_by_buyer / deleted_by_seller) | Низкий | B2 |
| B14 | GetUserOnlineAction: Centrifugo presence API или last_seen в Redis | Низкий | B1 |
Frontend (Nuxt/Vue)
| # | Задача | Приоритет | Зависимости |
|---|---|---|---|
| F1 | Zod-схемы: entities/conversation, entities/message | Высокий | — |
| F2 | SharedWorker: chat.shared-worker.ts — centrifuge-js, протокол, fallback | Высокий | — |
| F3 | useCentrifugo(): composable-обёртка над SharedWorker | Высокий | F2 |
| F4 | useConversations(): список, cursor pagination, фильтр, реактивное обновление | Высокий | F1, F3 |
| F5 | useMessages(convId): история, отправка, optimistic updates, markRead | Высокий | F1, F3 |
| F6 | ConversationCard.vue: entity-компонент карточки диалога | Высокий | F1 |
| F7 | MessageBubble.vue: entity-компонент пузыря (text/image/system) | Высокий | F1 |
| F8 | Страница /cabinet/messages/index.vue: список диалогов, фильтры, infinite scroll | Высокий | F4, F6 |
| F9 | Страница /cabinet/messages/[id].vue: переписка, input, скролл, шапка с товаром | Высокий | F5, F7 |
| F10 | ContactSellerButton.vue: «Написать продавцу» → POST /conversations → redirect | Высокий | — |
| F11 | ChatUnreadBadge.vue: бейдж в хедере, реактивно из Centrifugo | Высокий | F3 |
| F12 | useTyping(): дебаунс 3с, автосброс 4с | Средний | F3 |
| F13 | useChatNotifications(): звук + document.hidden | Средний | F3 |
| F14 | useChatImageUpload(): выбор файла, локальный превью, multipart | Средний | F5 |
| F15 | MessageStatus.vue: галочки (sent → delivered → read) | Средний | F1 |
| F16 | Навигация cabinet layout: пункт «Сообщения» + бейдж в sidebar | Высокий | F11 |
| F17 | i18n: ключи локализации для чата (ru.json) | Средний | — |
| F18 | Online-статус: «в сети» / «был(а) 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