Skip to content

ТЗ — Чат/онлайн-консультант (cms/chat)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Виджет чата на сайте с операторами в Filament: назначение диалогов, статусы онлайн, история переписки, офлайн-режим с падением в лид ядра. Новая разработка на realtime-шине студии.

  • Виджет чата на публичном сайте, realtime через cms/realtime;
  • операторы в Filament: список онлайн, назначение диалога оператору, переключение статуса;
  • история диалогов с поиском и фильтром по оператору/периоду;
  • офлайн-режим (нет доступных операторов) → форма превращается в лид ядра;
  • автоответы и приветственное сообщение (настраиваемый текст, задержка первого показа);
  • передача файлов в диалоге в пределах лимитов медиатеки ядра; скан вложений — через provides-контракт upload-scanner медиатеки (карантин pending до вердикта; 0 реализаций — скан пропускается штатно, ревизия №2, п. 4);
  • рейтинг диалога от посетителя после завершения (оценка + комментарий).

Зависимости и выключение

requires: cms/realtime (доставка сообщений/typing в реальном времени; LeadService и MediaService — контракты ядра cms/core-contracts, доступны всегда, это не отдельные модули) · suggests: cms/notifications-bus (отправка транскрипта диалога на email; не установлен — NotificationDispatch уходит в log-fallback ядра, запрос транскрипта не падает, но письмо не доставляется — см. «Крайние случаи»)

Поведение при выключении: виджет чата скрывается на сайте, история диалогов сохраняется в БД для просмотра из Filament (без возможности отвечать в реальном времени). Активные на момент выключения диалоги переходят в статус closed с системной пометкой (реакция на ModuleDisabled, канал 1) — посетитель видит, что виджет пропал, не зависает в ожидании ответа, которого не будет (§3 стандарта, «выключение = деградация»).

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

ТаблицаКлючевые поляПримечание
cms_chat_conversationsid, visitor_id, user_id (nullable), operator_id (nullable), site_id (nullable), city_id (nullable), status, lock_version, assigned_at, closed_at, rating, rated_commentдиалог, статус (open/closed/offline)
cms_chat_messagesid, conversation_id, sender_type, sender_id, body, attachment_path, created_atсообщения диалога

operator_id — FK constrained()->nullable()->index(); user_id — FK constrained()->nullable()->index() (привязка гостевого диалога к пользователю после логина, см. «Крайние случаи», склейка); site_id/city_id — FK nullable()->index(), измерения по правилу §4 стандарта (ниже); conversation_id в cms_chat_messages — FK constrained()->index(). status — PHP Enum (open/closed/offline); индекс на (operator_id, status) под панель оператора; индекс на visitor_id для резолва диалога гостя по cookie. lock_version — optimistic lock на редактируемые поля (статус, рейтинг); назначение оператора — отдельная атомарная операция (compare-and-swap по operator_id IS NULL, не через lock_version — см. «Крайние случаи»).

ПДн-паспорт. body сообщений, attachment_path, rated_comment и IP/UA в логе старта диалога — персональные данные (переписка с посетителем, 152-ФЗ). Ретеншн — chat.history_retention_days (см. «Настройки»), джоба очистки — часть «Фоновой работы». Участие в «выгрузить всё по субъекту»/«забыть по запросу»: диалоги и сообщения, где visitor_id/user_id совпадает с субъектом запроса, отдаются целиком либо анонимизируются хуком ядра (body[удалено по запросу], вложение удаляется из медиатеки через MediaService).

Входные и выходные данные

Whitelist-принцип: всё, что не перечислено ниже как вход, модуль обязан отвергать — неизвестное поле в теле запроса, чужой conversation_id без прав доступа, канал realtime вне chat.{id} (см. «Безопасность»).

Входы

ИсточникДанные/поляЧем валидируется
Форма старта диалога (POST /api/v1/chat/conversations, публичный)visitor_id (cookie/сессия), первое сообщение body, опционально name/emailFormRequest whitelist, rate-limit на старт, chat.max_message_length
Отправка сообщения (POST /api/v1/chat/conversations/{id}/messages)conversation_id, body, attachment (файл)FormRequest + проверка принадлежности отправителя диалогу (см. «Безопасность», IDOR), chat.max_message_length, MIME-whitelist медиатеки, chat.max_attachment_mb, скан upload-scanner (карантин pending до вердикта)
Оценка диалога (POST /api/v1/chat/conversations/{id}/rate)rating (1–5), commentFormRequest, только для status=closed, только один раз
Filament: назначение оператораconversation_id, operator_idpermission chat.manage, атомарное назначение (CAS)
Filament: смена статуса/закрытиеstatuspermission chat.operate/chat.manage, lock_version
Realtime-канал «печатает» (cms/realtime, канал 4 requires)conversation_id, is_typing (bool)whitelist имени канала chat.{id} в cms/realtime, эфемерно — не пишется в БД
Запрос транскрипта на email (POST /api/v1/chat/conversations/{id}/transcript)conversation_id, emailFormRequest (валидный email), throttle, доступ только участнику диалога/оператору
Attach гостевого диалога к пользователю (POST /api/v1/chat/conversations/{id}/attach)conversation_id, user_id берётся из RequestContext, не из тела запросатокен авторизации пользователя + принадлежность visitor_id текущей сессии — см. «Крайние случаи», склейка

Выходы

ПотребительДанныеФормат
Виджет чата (браузер)сообщения диалога, статус оператора, typing-индикаторконверт {data, meta} + realtime-события cms/realtime
Filament (панель оператора)список диалогов, история, рейтинги, метрикиBlade/Livewire через сервис модуля
Ядро: LeadServiceлид из офлайн-обращениявызов LeadService::create() (канал 4) → издаёт канонический LeadCreated (ядро); источник chat, ссылка на conversation_id
cms/notifications-bus (NotificationDispatch)транскрипт диалога на emailтекст сообщений + ссылки на вложения (не бинарники)
cms/realtime (requires, канал 4)сообщения/статусы/typingсервис-вызов broadcast() на канал chat.{id}
События ChatConversationStarted/...WentOffline/...Rated/др.фактканал 1, payload — id сущностей, без тела переписки (ПДн не в событии)
cms:chat:close-stale/purge-history/status --jsonстатус выполнения, диагностикаJSON в stdout

Настройки (группа chat)

КлючТипДефолтaffectsPageCacheОписание
chat.widget_positionstringbottom-rightнетПозиция виджета на странице
chat.greeting_delay_secondsint15нетЗадержка автоприветствия
chat.offline_to_leadbooltrueнетПревращать офлайн-обращения в лид ядра
chat.max_attachment_mbint10нетЛимит вложения (в пределах лимитов медиатеки)
chat.max_message_lengthint2000нетМаксимальная длина одного сообщения в символах
chat.max_concurrent_conversations_per_operatorint5нетЛимит одновременных активных диалогов на оператора — защита от перегрузки
chat.history_retention_daysint365нетСрок хранения истории диалогов (ПДн-ретеншн); 0 — без ограничения, требует явного обоснования в docs/module.md
chat.emergency_disableboolfalseдаKill-switch: аварийно скрывает форму старта диалога на сайте (например при спам-атаке через форму), не выключая модуль целиком — история и панель оператора остаются доступны

API

МетодПутьДоступНазначение
POST/api/v1/chat/conversationspublic (rate-limit)Начать диалог
POST/api/v1/chat/conversations/{id}/messagespublic/auth (участник)Отправить сообщение
POST/api/v1/admin/chat/conversations/{id}/assignadmin (chat.manage)Назначить оператора
POST/api/v1/chat/conversations/{id}/ratepublicОценить диалог после завершения
POST/api/v1/chat/conversations/{id}/attachauth (участник сессии)Приклеить гостевой диалог к вошедшему пользователю
POST/api/v1/chat/conversations/{id}/transcriptpublic/auth (участник или оператор)Запросить транскрипт диалога на email

Компоненты

Блоки (BlockRegistry): «Виджет чата» — lazy-load по видимости/взаимодействию (инициализация WebSocket-подключения и JS-бандла откладывается до первого скролла/клика, не блокирует LCP страницы, §8 стандарта). Filament: панель оператора (список диалогов, статус онлайн/офлайн, назначение, метрики — среднее время первого ответа, число зависших диалогов), история диалогов с рейтингами, действие «Приклеить диалог к пользователю вручную» (permission chat.manage, резерв на случай несработавшего attach — см. «Крайние случаи»). Команды: cms:chat:close-stale --json, cms:chat:purge-history --json (ретеншн), cms:chat:status --json (диагностика: диалоги без оператора, глубина очереди). Демо-сидер: несколько диалогов с сообщениями и рейтингами для галереи блоков /_gallery.

События и обмен

СобытиеКогдаPayload
ChatConversationStartedпосетитель начал диалогconversation_id
ChatConversationWentOfflineнет доступных операторов, создан лидconversation_id, lead_id
ChatConversationRatedдиалог оценён посетителемconversation_id, rating
ChatOperatorAssignedоператор назначен (в т.ч. переназначение)conversation_id, operator_id
ChatConversationClosedдиалог закрыт (вручную, по расписанию или при выключении модуля)conversation_id, closed_reason
ChatConversationAttachedToUserгостевой диалог приклеен к вошедшему пользователюconversation_id, user_id

Слушает: — (модуль сам не подписывается на чужие события, кроме служебного ModuleEnabled/ModuleDisabled собственного графа зависимостей; realtime-доставка сообщений — сервис-вызовом к cms/realtime по requires).

Таблица взаимодействий

Сущность/модульКаналНаправлениеЧто происходит
Ядро: LeadServiceсервис-вызов (канал 4, core-contract)outсоздание лида при уходе диалога в офлайн (chat.offline_to_lead=true)
Ядро: MediaServiceсервис-вызов (канал 4, core-contract)in/outзагрузка и валидация вложений сообщений в пределах лимитов медиатеки
Ядро: RequestContextчтение (служебное, вне пяти каналов)insite_id/city_id/locale/пользователь для диалога и панели оператора
Ядро: settings-storeчтение группы chat из кешаinлимиты, kill-switch, ретеншн — 0 запросов на горячем пути
cms/realtimerequires → сервис-вызов (канал 4)outдоставка сообщений/typing/статусов подписчикам канала chat.{id}
cms/notifications-bus (NotificationDispatch, suggests)сервис-вызов core-contract → шинаoutотправка транскрипта диалога на email; не установлен — log-fallback ядра (см. «Крайние случаи»)
Событие ModuleEnabled/ModuleDisabled (ядро, канал 1)событиеinвыключение cms/realtime переводит модуль на деградацию (см. «Зависимости и выключение»)
Filament (аудитория «оператор/менеджер»)компонент ↔ модуль (представительство)outпанель диалогов через сервис модуля, не запросы из шаблона

Фоновая работа

Именованная очередь chat для cms:chat:close-stale (закрытие зависших диалогов по расписанию через ScheduleRegistrar ядра) и cms:chat:purge-history (ретеншн — удаление или анонимизация диалогов старше chat.history_retention_days); обе джобы идемпотентны (повторный прогон не меняет уже обработанные записи).

Метрики и алерты (§15 стандарта): среднее время первого ответа оператора, число «зависших» диалогов (open без оператора дольше N минут), глубина очереди chat — видны в Pulse/health. Алерт: доля диалогов вне SLA-окна ответа выше порога.

Мини-ранбук:

СимптомЧто проверитьКоманда
Диалоги висят без оператораОнлайн ли операторы, не отстаёт ли очередь chatcms:chat:status --json
Сообщения не долетают до оператора в реальном времениДоступность cms/realtime/Reverbhealth-чек cms/realtime (см. realtime.md); REST-фолбэк должен работать штатно
cms_chat_messages растёт бесконтрольноСтоит ли cms:chat:purge-history в расписанииcms:chat:purge-history --dry-run --json
Гость и вошедший пользователь видят два диалогаОтработал ли attach на фронтенде при логинеFilament-действие «Приклеить диалог к пользователю вручную» (chat.manage)

Бэкап/рестор. Таблицы cms_chat_conversations/cms_chat_messages (включая вложения в медиатеке) — в бэкапе как обычные данные модуля. Не бэкапится: realtime-состояние (кто онлайн, presence, typing) — живёт в cms/realtime/Redis, собирается заново по факту переподключений после рестора.

Производительность и кеш

Ожидаемые объёмы. Типичный сайт: единицы-десятки одновременных диалогов, сотни сообщений в день; крупный сайт с несколькими операторами — десятки диалогов, до нескольких тысяч сообщений в день. cms_chat_messages растёт монотонно — кандидат на партиционирование по created_at при росте истории на крупных инсталляциях.

Горячие пути и бюджет запросов. Панель оператора «список активных диалогов» — один запрос по индексу (operator_id, status) с ->with() на оператора и превью последнего сообщения (денормализованное поле либо оконная функция — не N запросов на N диалогов, N+1 запрещён §10 стандарта). Виджет чата при открытии — один запрос истории (keyset-пагинация, не вся переписка целиком). Чтение настроек группы chat — 0 запросов (кеш группы).

Индексы. См. «Модель данных»: (operator_id, status) — панель оператора; visitor_id — резолв диалога гостя по cookie; user_id — резолв после логина/склейка; conversation_id в cms_chat_messages — история; при партиционировании — created_at (BRIN, журнальный характер таблицы сообщений).

Теги кеша и инвалидация. Теги chat:conversation:<id> — инвалидируются событиями смены статуса диалога (ChatOperatorAssigned, ChatConversationClosed).

Персональные данные — исключение из общего кеша. Переписка (посетитель/оператор) — персональные данные, в общий page-cache не попадает ни в каком виде: ни история диалога, ни счётчик непрочитанных на публичной странице — эти данные всегда идут мимо page-cache, через realtime/AJAX с текущим RequestContext, не через кешируемый HTML-фрагмент.

Безопасность

Оператор видит только назначенные ему или неназначенные диалоги, не чужие закреплённые (проверка через Policies). Вложение сверх max_attachment_mb или запрещённого типа отклоняется (whitelist MIME медиатеки ядра); вредоносное содержимое — скан upload-scanner (карантин pending до вердикта: получателю вложение не отдаётся, пока файл не прошёл проверку; 0 реализаций — скан пропускается штатно). Realtime-обмен сообщениями не допускает XSS (санитизация на входе и эскейпинг на выводе — двойной барьер). Rate-limit на старт диалога.

IDOR (посетитель и чужой диалог). conversation_id в URL — не единственная защита: доступ к диалогу для неавторизованного посетителя проверяется по visitor_id текущей сессии/cookie, для вошедшего — по user_id из RequestContext; перебор conversation_id без валидной сессии/токена отдаёт 403, не данные чужого диалога. Публичный идентификатор диалога — ULID/UUID, не последовательный автоинкремент (§4 стандарта: перебор ресурсов).

ПДн. Переписка, вложения и rated_comment — персональные данные (см. «Модель данных», ПДн-паспорт); в логи и аналитику модуль не пишет тело сообщений — только обезличенные идентификаторы диалога/события.

Матрица ролей (permissions chat.view, chat.operate, chat.manage + настройки группы chat):

Действие / permissionОператорСтарший операторМенеджерStudio
chat.view — свои назначенные диалоги
chat.view — все диалоги, включая чужие
chat.operate — отвечать в назначенном диалоге, менять свой статус онлайн
chat.manage — назначить/переназначить чужой зависший диалог, массовое закрытие
Настройки группы chat (лимиты, chat.emergency_disable)

Права: chat.view, chat.operate, chat.manage.

UX-требования

Посетитель. Отправленное сообщение отображается сразу (оптимистичный UI), статус доставки («отправлено» / «ошибка — повторить») приходит следом, не блокируя ввод следующего сообщения. Индикатор набора текста оператором — через cms/realtime (канал chat.{id}), пропадает по таймауту, если оператор перестал печатать. История диалога сохраняется при обновлении страницы (резолв по visitor_id из cookie, не чистый лист при F5). Виджет адаптивен на мобильных: не перекрывает контент, доступен с клавиатуры (фокус-ловушка при открытии, Esc закрывает). При недоступности cms/realtime — деградация на REST-polling, не «зависший» виджет без обратной связи.

Оператор. Пустое состояние списка диалогов — «нет активных диалогов» с подсказкой, не голая пустая таблица. Массовые действия: выбрать несколько зависших диалогов в списке → «закрыть» одним действием (та же логика, что у cms:chat:close-stale, но ручной триггер из Filament, permission chat.manage). Ошибки — человеческим языком: «не удалось отправить — потеряно соединение, переподключение...» вместо стектрейса. Необратимые операции — закрытие диалога с непрочитанным сообщением посетителя, массовое закрытие диалогов — только с модальным подтверждением.

Крайние случаи и типовые баги

  • Оператор офлайн, chat.offline_to_lead=true → лид создаётся каноническим способом через core-contract LeadService::create() (не своя таблица заявок — LeadService и есть «форма/чат/UGC → заявка» из core.md), источник лида — chat, ссылка на conversation_id для дальнейшей склейки истории с заявкой ядра; ядро издаёт канонический LeadCreated — потребители (CRM-интеграция, аналитика) слушают его на уровне ядра, модуль не дублирует событие своим именем.
  • Оператор офлайн, chat.offline_to_lead=false → форма старта диалога не открывается вовсе: посетитель видит сообщение «сейчас никого нет онлайн, напишите позже» вместо пустого работающего виджета без единого ответа — пустой активный чат без операторов сам по себе плохой UX и источник лишних обращений в поддержку.
  • История диалога гостя после логина (склейка) → посетитель начал диалог анонимно (visitor_id по cookie), затем вошёл в кабинет: фронтенд обязан вызвать POST /api/v1/chat/conversations/{id}/attach при обнаружении авторизации — диалог получает user_id, оператор видит непрерывную историю вместо двух разных диалогов. Событие UserLoggedIn канонизировано ревизией ядра 14.07.2026 (п. 4) — основная склейка событийная (слушатель UserLoggedIn привязывает диалоги по visitor_id из payload/сессии); явный attach с фронтенда остаётся резервным путём для SPA-виджета. Оба пути идемпотентны (no-op для уже привязанного диалога).
  • Два оператора взяли один диалог одновременно → назначение — атомарная операция UPDATE ... WHERE operator_id IS NULL (compare-and-swap), не read-then-write: первый запрос назначает и получает 200, второй — 0 затронутых строк → 409 «диалог уже назначен оператору N», не тихий перехват чужого диалога.
  • Транскрипт диалога на email по запросу → сервис модуля собирает текст сообщений (без бинарных вложений — только подписанные ссылки на файлы медиатеки) и передаёт через NotificationDispatch (core-contract) в cms/notifications-bus, канал email резолвится провайдером mail-transport. ⚠️ Противоречие: без активного mail-transport-провайдера cms/notifications-bus молча уходит в log-fallback ядра (см. notifications-bus.md) — API отдаст «успех», хотя письмо никуда не ушло. Решение: POST .../transcript различает «поставлено в очередь» и «гарантированно не доставлено» — при log-fallback API возвращает явное предупреждение, не молчаливый 200.
  • Выключение cms/chat посреди активного диалога → активные диалоги переходят в closed с системной пометкой при обработке ModuleDisabled; посетитель видит, что виджет пропал, не зависает в ожидании ответа (см. «Зависимости и выключение»).
  • Сбой/таймаут cms/realtime → сообщения продолжают приниматься через обычный REST (POST .../messages), доставка получателю — при следующем poll/reconnect; виджет и панель оператора не блокируются ожиданием WebSocket-соединения.
  • Диалог с сотнями сообщений / пустой диалог → история грузится keyset-пагинацией (не вся переписка одним запросом ни в виджет, ни в панель оператора); пустой диалог (0 сообщений, только ChatConversationStarted) — валидный кейс, не ошибка.
  • Мультигородской сайт: к какому городу привязан диалог/оператор → ⚠️ Противоречие: исходная модель данных не несла измерений site_id/city_id, что противоречило §4 стандарта («контентные сущности несут измерения locale/city/site, nullable») — на мультигородском сайте оператор одного города рисковал увидеть диалоги другого. Решение (внесено в это ТЗ): nullable site_id, city_id добавлены в cms_chat_conversations (см. «Модель данных»), скоуп — из RequestContext на старте диалога; панель оператора по умолчанию фильтрует по городу/сайту оператора (привязка оператора к городу — часть профиля пользователя ядра, вне модуля chat). Локаль отдельным измерением не хранится: сообщения не переводимый контент, локаль резолвится из RequestContext на момент рендера.
  • Противоречивые настройки (chat.offline_to_lead=false при ненулевом chat.greeting_delay_seconds) → если операторов нет онлайн ни при каких условиях, ожидание приветствия перед показом «никого нет онлайн» — лишняя задержка без смысла: офлайн-сообщение показывается немедленно, greeting_delay_seconds применяется только когда хотя бы один оператор потенциально может ответить.
  • Вложение удалено из медиатеки, ссылка осталась в истории → сообщение с «осиротевшим» attachment_path рендерится с понятной заглушкой («файл недоступен»), не 404-страницей и не битой ссылкой без объяснения.
  • Вложение в карантине upload-scanner на момент открытия диалога → получатель видит сообщение с пометкой «файл проверяется», ссылка на скачивание появляется после положительного вердикта; отрицательный вердикт — вложение не публикуется, сообщение остаётся с пометкой «файл заблокирован» (тот же барьер, что у cms/ugc, ревизия №2, п. 4).

Донорский код

Донор: — не применимо, донора нет, новая разработка на realtime-шине студии (§16 стандарта, «Миграция legacy-данных»: cms:chat:import-legacy не требуется).

Тесты и приёмка

  • [ ] Контрактный тест: обращение при отсутствии онлайн-операторов и offline_to_lead=true создаёт лид через LeadService с привязкой к диалогу;
  • [ ] offline_to_lead=false показывает сообщение «сейчас никого нет онлайн», а не пустой рабочий виджет;
  • [ ] Вложение сверх max_attachment_mb или запрещённого типа отклоняется (whitelist MIME медиатеки);
  • [ ] Вложение без прохождения скана upload-scanner не отдаётся получателю (карантин pending), 0 реализаций — скан пропускается штатно;
  • [ ] Сообщение длиннее chat.max_message_length отклоняется;
  • [ ] Оператор видит только назначенные ему или неназначенные диалоги, не чужие закреплённые;
  • [ ] Гонка двух операторов: второе назначение получает 409, не тихий перехват (CAS-тест);
  • [ ] Attach гостевого диалога к пользователю склеивает историю по visitor_id → user_id, повторный вызов идемпотентен;
  • [ ] Рейтинг можно поставить только один раз на завершённый диалог;
  • [ ] Деградация при выключении модуля закрывает активные диалоги системной пометкой, не оставляет их подвисшими;
  • [ ] Транскрипт на email: при отсутствии mail-transport-провайдера API явно сообщает о log-fallback, не молчаливый успех;
  • [ ] IDOR: доступ к чужому диалогу по перебору conversation_id без валидной сессии/токена отдаёт 403;
  • [ ] Список диалогов оператора грузится без N+1 (бюджет запросов — контрактный тест на (operator_id, status));
  • [ ] Измерения site_id/city_id диалога работают корректно и при их отсутствии (оба режима — контрактный тест §4 стандарта);
  • [ ] Ретеншн истории: джоба cms:chat:purge-history анонимизирует/удаляет диалоги старше chat.history_retention_days, идемпотентна;
  • [ ] chat.emergency_disable скрывает форму старта диалога без выключения модуля целиком;
  • [ ] Realtime-обмен сообщениями не допускает XSS (санитизация на входе и эскейпинг на выводе);
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только chat_test; migrate:fresh/refresh/reset, db:wipe запрещены.

Внутренняя база знаний студии