Event Contract: pin.messenger.message-posted (MessagePostedEvent)
Версионируемый контракт интеграционного события «в чат отправлено сообщение». Consumer-документы
(notifications) ссылаются сюда, не переописывая payload. Событие фиксирует факт публикации сообщения
([*] → Sent, INV-MSG-3) и несёт корреляционные id + bounded превью — notifications рассылает
offline push/email адресатам без синхронного запроса в messenger и без тела/PII. Дом полей сообщения
— message.md; дом чата/участников — chat.md,
participant.md.
Назначение
messenger доставляет сообщения in-app через SignalR (persist-then-push); для получателей офлайн
push/email рассылает notifications как консюмер этого события. Событие сознательно не несёт список
получателей и тело сообщения: адресаты выводятся notifications из собственной модели (активные
участники чата минус отправитель, с учётом Participant.IsMuted), а тело — PII (не покидает границу
messenger). Публичное как интеграционный триггер уведомлений; класс данных — Internal.
Metadata
| Поле | Значение |
|---|---|
| Event type | pin.messenger.message-posted |
| Класс события | MessagePostedEvent |
| Topic | pin.messenger.message-posted.v1 |
| Partitions / key | 6 / key = ChatId (порядок сообщений внутри чата per-partition) |
| ContractVersion | 1 |
| Producer | messenger: отправка сообщения (send-text-message / send-attachment-message, дом message.md) |
| Статус | draft |
Событие публикуется атомарно (в одном согласованном изменении) с фиксацией сообщения и денормом чата
(Chat.RegisterPosted, INV-CHAT-5) через транзакционный outbox. Партиционирование по ключу ChatId
(не по id сообщения) — так сообщения одного чата попадают в один раздел и обрабатываются notifications
по порядку.
Payload MessagePostedEvent
Корреляционные поля + bounded-превью (для UX уведомления). Тело сообщения (Body/Caption) и
Sensitive-поля (IpAddress/UserAgent) в payload запрещены (INV-MSG-6). Дом полей —
message.md.
| Поле | Тип | Обязательность | Источник (поле агрегата) | Compatibility | Описание |
|---|---|---|---|---|---|
ChatId | Guid | Да | Message.ChatId | Immutable | Чат-лента (Kafka key). notifications резолвит адресатов по составу. |
ChatKind | string (enum ChatKind) | Да | Chat.Kind | Additive only | Direct / Deal — тексты/группировка уведомления. |
MessageId | Guid | Да | Message.Id | Immutable | Сообщение; бизнес-ключ идемпотентности. |
Seq | long | Да | Message.Seq | Immutable semantics | Per-chat монотонный порядковый (порядок/дедуп у consumer-а). |
Kind | string (enum MessageKind) | Да | Message.Kind | Additive only | Text / Attachment / EventCard. |
SenderUserId | Guid? | Нет | Message.SenderUserId | Immutable | Автор; null — система/EventCard (self-notify исключается по нему). |
SenderRole | string? (enum ParticipantRole) | Нет | Message.SenderRole | Additive only | Роль автора на момент отправки. |
Preview | string | Да | derived | Additive only | Bounded превью (≤256): Text — обрезка тела; Attachment — FileName/класс; EventCard — DefaultText. Не полный текст. |
SentAt | DateTimeOffset | Да | Message.SentAt | Immutable semantics | Момент отправки (тайминг/группировка уведомления). |
ChatId/MessageId иммутабельны; Preview — bounded-производное, а не тело (правки сообщения в окне
≤60с события повторно не публикуют — уведомление одноразовое по факту Sent).
Когда публикуется
| Условие | Переход состояния | Согласованность |
|---|---|---|
Пользователь отправил Text/Attachment (Chat.Status = Active, CommunicationPolicy.CanMessage) | [*] → Sent | сообщение, Chat.RegisterPosted и факт отправки — в одном согласованном изменении (INV-CHAT-5 / INV-MSG-3) |
Правка/удаление в окне (→ Edited/Deleted) и системные EventCard-карточки от консюмеров crm
событие не публикуют: уведомление привязано к факту первичной отправки пользовательского сообщения.
EventCard рассылаются собственными триггерами crm/notifications, не через этот топик.
Потребители (consumers)
| Consumer-контекст | Эффект (событие → что меняется) | Идемпотентность | Задержка (SLA) |
|---|---|---|---|
notifications | Fan-out offline push/email адресатам (состав + IsMuted — из собственной проекции notifications; тело не входит, only-preview) | first-seen по MessageId | < 1 мин |
notifications подавляет self-notify (recipient == SenderUserId) и замьюченных участников; отправитель
и online-получатели уже получили сообщение через SignalR-hub (persist-then-push, не через это событие).
Гарантии доставки, порядок и обработка отказов
- Порядок per-
ChatId. КлючChatId(неMessageId) кладёт все сообщения одного чата в один раздел —notificationsобрабатывает их по возрастаниюSeq. Между чатами порядка нет. - At-least-once + идемпотентность. Доставка хотя бы раз;
notificationsдедуплицирует поMessageId(first-seen) — повторная доставка не задваивает offline-push/email. - Fan-out по реплике потребителя. Список адресатов и
IsMutednotificationsберёт из собственной проекции состава чата — она eventual относительноmessenger. В узком окне возможен push только что вышедшему участнику или пропуск только что добавленного; расхождение самокорректируется по мере догона реплики состава и не блокирует доставку остальным. - Только первичная отправка порождает событие. Правка/удаление (
→ Edited/Deleted) и системные EventCard событие не публикуют — уведомление одноразовое по фактуSent; EventCard рассылаются собственными триггерамиcrm/notifications. - Retry/DLQ. Транзиентный сбой consumer-а — ретрай по offset; poison после исчерпания попыток → DLQ + алерт, offset продвигается (offline-уведомление может опоздать; само сообщение доставлено online через hub и доступно по REST).
Версионирование и совместимость
ContractVersion = 1; при несовпадении версии входящего события с текущей consumer логирует warning и продолжает (апкаст схемы не выполняется). Топик кодирует версию суффиксом.v1по конвенции.- Additive-only для minor: новые поля — optional с безопасным default, без bump.
- Breaking — новый
ContractVersion = 2+ топикpin.messenger.message-posted.v2+ dual-publish + deadline миграции consumers + sunset.v1. - Unknown enum
Kind/ChatKind/SenderRole— fallback: сохранить строку как есть + warning (уведомление деградирует до generic-текста, не fail). - Порядок сообщений одного чата гарантирован ключом
ChatId; consumer держит дедуп поMessageId.
Безопасность
| Поле | Класс данных | Правило |
|---|---|---|
ChatId, ChatKind, MessageId, Seq, Kind, SenderUserId, SenderRole, SentAt | Internal | include (корреляция; не публичный контент) |
Preview | Internal (bounded) | include с жёсткой обрезкой ≤256 и без полного тела вложения |
Body / Caption (тело) | PII | exclude — не покидает границу messenger (INV-MSG-6) |
IpAddress / UserAgent | Sensitive | exclude — не в события/логи (INV-MSG-6) |
Событие несёт только bounded-превью, id и enum-статусы. НЕ содержит полного тела, ФИО/телефонов, IP/UA, secrets, tokens, платёжных данных, бинаря вложений.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: MessageEntity (TPH: Text/Attachment/EventCard)
- API (2): POST /api/messenger/chats/{chatId}/messages/attachment — Отправить вложение, POST /api/messenger/chats/{chatId}/messages/text — Отправить текстовое сообщение
- Use Cases (2): UC-MSG-001. Открыть личный чат по поиску пользователя по телефону, UC-MSG-008. Добавленный участник появляется в чате сделки/лида
- События (1): События сервиса messenger: реестр топиков
- registries (1): Реестр событий PIN
Связанные документы
- Источник (операция):
send-text-message/send-attachment-message(API-карта в README); сущности — message.md (MessagePosted, INV-MSG-3/6, bounded-preview), chat.md (RegisterPosted, INV-CHAT-5), participant.md (IsMuted— резолв адресатов уnotifications). - Соседние контракты сервиса — реестр событий.
- ADR: 0055 мини-мессенджер, 0056 simplicity-first.