Перейти к основному содержимому

Entity / Aggregate: MessageEntity (TPH: Text/Attachment/EventCard)

Дом полей агрегата «Сообщение» контекста messenger (greenfield, ADR-0055). Одна лента чата = поток MessageEntity. Все проекции (list-messages, unread, push в hub, payload в notifications) ссылаются сюда и описывают дельту; дочерние MessageEditEntity и EventCardRecipientEntity документируются здесь как часть агрегата.

Назначение

MessageEntity — единица переписки участников чата сделки: пользовательский текст, вложение или системная event-карточка (событие сделки из crm). TPH по дискриминатору Kind — три подтипа в таблице messenger.messages. Собственный агрегат: ссылается на чат через ChatId, но не грузится вместе с чатом — высокообъёмная сущность, читается пагинированно по Seq (per-chat монотонный из Chat.LastSeqCounter, chat.md). Event-карточки материализуют консюмеры Kafka-событий crm идемпотентно; пользователь их не правит. Бинарь вложений — во внешнем storage/CDN, в messenger только FileId + метаданные.

Классификация

ПолеЗначение
Контекстmessenger
KindAggregateRoot — собственный агрегат; ссылается ChatId, не грузится с чатом (высокообъёмная лента). Дочерние MessageEditEntity/EventCardRecipientEntity — Entity внутри агрегата
Source of truthmessenger (метаданные + контент); бинарь вложений — storage/CDN
Таблицаmessenger.messages (TPH, дискриминатор Kind)
Tenant isolationСтолбца tenant на сообщении нет — граница наследуется от чата (ChatId → Chat.TenantId); видимость — членство в чате (record-level)

Поля (базовая часть TPH — все подтипы)

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKapp (v7)Идентификатор сообщения.
ChatIdGuidДаFK fk_messages_chat_id → messenger.chats(id) cascade, index ix_messages_chat_idappЧат-лента (chat.md).
SeqlongДаbigint; unique ux_messages_chat_seq (ChatId,Seq)Chat.LastSeqCounterPer-chat монотонный порядковый — основа порядка ленты и unread.
KindMessageKindДаTPH discriminator, .HasConversion<string>(), varchar(24), index (ChatId,Kind)appText / Attachment / EventCard.
SenderUserIdGuid?Нетindex ix_messages_senderappАвтор; null — система/карточка.
SenderRoleParticipantRole?Нет.HasConversion<string>(); enum — chat.mdappРоль автора на момент отправки.
IsSystemboolДаdefault falseapptrue — EventCard/системное.
SentAtDateTimeOffsetДаappМомент отправки.
EditableUntilDateTimeOffset?Нет= SentAt + 60s; только user-сообщения (IsSystem = false)appГраница окна правки/удаления (INV-MSG-1).
EditedAtDateTimeOffset?Нетстолбец updated_atappМомент последней правки; null до правки.
DeletedAtDateTimeOffset?Нетsoft-deleteappМомент удаления (тело скрыто).
IpAddressIpAddress?Нетinet; только user-сообщенияappIP отправителя. Sensitive/internal.
UserAgentstring?Нетvarchar(512); только user-сообщенияappUA отправителя. Sensitive/internal.
CreatedAtDateTimeOffsetДаappЗапись строки.

Подтип TextMessageEntity (Kind = Text)

ПолеТипОбязательностьОграниченияОписание
BodystringДаtext, длина 1..8000Текст сообщения. PII.

Подтип AttachmentMessageEntity (Kind = Attachment)

ПолеТипОбязательностьОграниченияОписание
FileIdGuidДассылка на storage/CDNФайл; бинарь не хранится (INV-MSG-7).
FileNamestringДаvarchar(256)Оригинальное имя файла.
ContentTypestringДаvarchar(128)MIME-тип.
SizeByteslongДа> 0 and <= <limit> (конфигурация)Размер файла.
Captionstring?Нетvarchar(2000)Подпись к вложению. PII.
AttachmentKindAttachmentTypeДа.HasConversion<string>(), varchar(16)Класс вложения, выводится из ContentType.

Загрузка — вне форм: клиент получает presign у storage, отправляет только FileId + метаданные.

Подтип EventCardMessageEntity (Kind = EventCard, typed jsonb Payload)

ПолеТипОбязательностьОграниченияОписание
CardTypeEventCardTypeДа.HasConversion<string>(), varchar(48)Тип карточки (см. enum).
CategoryEventCardCategoryДа.HasConversion<string>(), varchar(16)TargetAction / Info.
ActionStatusEventCardActionStatusДа.HasConversion<string>(), varchar(16); для TargetAction init PendingСостояние целевого действия.
DefaultTextstringДаvarchar(1000); или i18n-ключ SEED-каталогаТекст карточки по умолчанию.
DealIdGuid?Нетлогическая ссылка на crmКорреляция чат ↔ сделка.
SourceEventIdGuidДаux ux_messages_card_source (ChatId,CardType,SourceEventId)= EventId Kafka-события crm (идемпотентность, INV-MSG-8).
Payloadtyped jsonbДаjsonb, ContractVersion = 1; модели нижеТипизированные данные карточки.

EventCard: SenderUserId = null, IsSystem = true, без IpAddress/UserAgent (INV-MSG-4).

Enum'ы

  • MessageKind { Text, Attachment, EventCard } (TPH-дискриминатор); AttachmentType { Image, Document, Audio, Video, Other }.
  • EventCardCategory { TargetAction, Info } (целевое действие / информационная); EventCardActionStatus { None, Pending, Completed }.

Enum EventCardType (источник Kafka → Category → payload)

Каталог текстов карточек — SEED в Migrator (i18n-ключи). Повтор доставки не дублирует карточку (INV-MSG-8).

EventCardTypeCategoryInit ActionStatusИсточник (topic crm)Payload-модель
UploadBookingDocumentsTargetActionPendingpin.crm.document-requested.v1CardUploadDocumentsPayload
UploadContractDocumentsTargetActionPendingpin.crm.document-requested.v1CardUploadDocumentsPayload
AttachPaymentProofTargetActionPendingpin.crm.document-requested.v1CardUploadDocumentsPayload
BookingExpiredTargetActionPendingpin.crm.booking-expired.v1CardBookingExpiredPayload
DealCreatedInfoNonepin.crm.deal-created.v1CardDealRefPayload
StageChangedInfoNonepin.crm.deal-stage-changed.v1CardStageChangedPayload
BookingConfirmedInfoNonepin.crm.booking-confirmed.v1CardDealRefPayload
BookingContractPaidInfoNonepin.crm.booking-paid.v1CardBookingPaidPayload
SentToRosreestrInfoNonepin.crm.deal-stage-changed.v1CardDealRefPayload
DealCompletedInfoNonepin.crm.deal-completed.v1CardDealRefPayload
DocumentAcceptedInfoNonepin.crm.document-status-changed.v1CardDealRefPayload
DocumentRejectedInfoNonepin.crm.document-status-changed.v1CardDealRefPayload
MemberAddedInfoNonepin.crm.deal-members-changed.v1CardDealRefPayload
MemberRemovedInfoNonepin.crm.deal-members-changed.v1CardDealRefPayload

Value objects

Value objects не используются. IpAddress — типизированный примитив (хранение inet), не generic string.

Модели JSONB (versioned)

ContractVersion = 1; совместимость additive-only, breaking → новый ContractVersion. Поля с ? — nullable; PII (ФИО/телефон/эскроу/бинарь) в payload запрещён — только id/типы/суммы/статусы.

Модель (ContractVersion = 1)Поля (v1)
CardUploadDocumentsPayload{ DealId: Guid, DocumentId: Guid, DocumentType: string, Title: string, RequestComment: string?, DueAt: DateTimeOffset? }
CardBookingPaidPayload{ DealId: Guid, BookingId: Guid, Amount: decimal, CurrencyId: short, Currency: CurrencySnapshot, PaidAt: DateTimeOffset }
CardStageChangedPayload{ DealId: Guid, FromStatus: string, ToStatus: string, ToStepName: string? }
CardBookingExpiredPayload{ DealId: Guid, BookingId: Guid, ExpiredAt: DateTimeOffset }
CardDealRefPayload{ DealId: Guid } (DealCreated/DealCompleted и прочие info-карточки со ссылкой на сделку)

CurrencyId в CardBookingPaidPayload — ссылка на запись справочника валют; Currency — snapshot-модель CurrencySnapshot (дом — handbook/domain/snapshots.md), скопированная из события-источника: display валюты и исторический след на момент оплаты, переименования справочника карточку не «догоняют».

Дочерние сущности агрегата

MessageEditEntity (таблица messenger.message_edits, append-only)

Снимок прошлого контента при правке/удалении; не отдаётся read-моделями, кроме admin-арбитража (INV-MSG-2).

ПолеТипОбязательностьОграниченияОписание
IdGuidДаPKИдентификатор снимка.
MessageIdGuidДаFK fk_message_edits_message_id → messenger.messages(id) cascade, index ix_message_edits_message_idСообщение.
RevisionintДа>= 1; unique (MessageId,Revision)Номер правки (1, 2, …).
PreviousBodystring?НетtextПрошлый текст (для Text). PII.
PreviousCaptionstring?Нетvarchar(2000)Прошлая подпись (для Attachment). PII.
EditedAtDateTimeOffsetДаМомент правки/удаления.
EditorUserIdGuidДаКто изменил (= автор, INV-MSG-1).
IpAddressIpAddress?НетinetIP правки. Sensitive.
UserAgentstring?Нетvarchar(512)UA правки. Sensitive.

EventCardRecipientEntity (таблица messenger.event_card_recipients)

Per-participant видимость и текст карточки. Нет строк-получателей ⇒ карточка видна всем с DefaultText (INV-MSG-5).

ПолеТипОбязательностьОграниченияОписание
IdGuidДаPKИдентификатор строки.
MessageIdGuidДаFK fk_card_recipients_message_id → messenger.messages(id) cascade; unique (MessageId,UserId)Карточка.
UserIdGuidДаindex ix_card_recipients_user_idПолучатель.
IsVisibleboolДаdefault trueВидна ли карточка получателю.
TextOverridestring?Нетvarchar(1000)Персональный текст вместо DefaultText.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
ChatEntityreference (родитель)ChatIdне грузится с чатом; читается пагинированно по ChatId, порядок по Seqcascade
MessageEditEntitychild (в агрегате)MessageIdappend-only при правке/удалении; в read-моделях скрыт (кроме admin)cascade
EventCardRecipientEntitychild (в агрегате)MessageIdinclude при list-messages — фильтр видимости per-participantcascade
Файл (storage/CDN)внешняяFileIdпресайн download; бинарь вне БД (INV-MSG-7)— (регламент storage)
DealEntity (crm)логическая (id в payload)DealId / Payload.DealIddisplay/корреляция UI

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-MSG-1Править/удалять может только автор (SenderUserId = actor), пока now <= EditableUntil (≤60с) и Kind != EventCard / IsSystem = false; после — immutableметод агрегата Edit/Delete + guardMSG_EDIT_WINDOW_EXPIRED / MSG_EDIT_FORBIDDEN
INV-MSG-2Каждая правка/удаление пишет MessageEditEntity (append-only снимок прошлого); история не отдаётся read-моделями, кроме admin-арбитражаEdit/Delete в одной транзакции
INV-MSG-3Seq выдаётся атомарно из Chat.LastSeqCounter под конкуренцией; порядок ленты — по SeqChat.RegisterPosted + unique (ChatId,Seq)MSG_SEQ_CONFLICT (retry)
INV-MSG-4EventCard/системные: SenderUserId = null, IsSystem = true, без IP/UA; пользователь не правит/не удаляетфабрика карточки + guardMSG_EDIT_FORBIDDEN
INV-MSG-5Видимость EventCard получателю R: нет recipient-строк ⇒ видно всем; иначе строка R c IsVisible = true; текст = TextOverride ?? DefaultTextlist-messages (фильтр per-participant)
INV-MSG-6IP/UA обязательны для user-сообщений, класс Sensitive — не в события/логи/ответы (кроме admin)отправка/правка сообщения + маппингMSG_ORIGIN_REQUIRED
INV-MSG-7Attachment хранит только FileId + метаданные; бинарь — в storageпроверка вложенияMSG_ATTACHMENT_INVALID
INV-MSG-8Идемпотентность карточек из crm: ux (ChatId,CardType,SourceEventId) — повторная доставка Kafka не дублируетconsumer события crm + unique— (skip дубля)

EventCard/системные (IsSystem = true) минуют Edited/Deleted — сразу иммутабельны (INV-MSG-4).

Конкурентность правки и нумерации. Окно правки/удаления (EditableUntil = SentAt + 60s) считается по серверному времени — авторитет границы у сервера, не у клиента. Две одновременные правки одного сообщения разрешает оптимистическая блокировка: проигравший перечитывает и повторяет, если ещё в окне; правка на границе окна отклоняется, когда сервер уже за EditableUntil (MSG_EDIT_WINDOW_EXPIRED). Выдача Seq под конкуренцией постов атомарна на строке чата (INV-MSG-3): при коллизии ux_messages_chat_seq проигравший получает следующий Seq (MSG_SEQ_CONFLICT → retry), порядок ленты остаётся монотонным.

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
MessagePostedдоменное[*] → SentChat.RegisterPosted (инкремент LastSeqCounter/unread) + realtime-dispatch push MessageReceived в hub
MessageEdited / MessageDeletedдоменноеправка/удаление в окнеpush MessageEdited / MessageDeleted в hub
MessagePostedEventинтеграционное, topic pin.messenger.message-posted.v1, ContractVersion = 1[*] → SentChatId, MessageId, Seq, Kind, SenderUserId?, SenderRole?, SentAt, Preview (bounded: для AttachmentFileName/тип, для EventCardDefaultText); без IP/UA; консюмер — notifications; контракт — ../events/message-posted.md

Карточки messenger не публикует — их порождают консюмеры crm идемпотентно (INV-MSG-8). Все события фиксируются через транзакционный outbox атомарно с мутацией (persist-then-push).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; unique ux_messages_chat_seq (ChatId,Seq); (ChatId,SentAt) — история; (ChatId,Kind); ix_messages_sender (SenderUserId); ux ux_messages_card_source (ChatId,CardType,SourceEventId); message_edits: PK, (MessageId,Revision); event_card_recipients: PK, ux (MessageId,UserId), ix_card_recipients_user_id
Партиционирование / retentionnone на целевой нагрузке (ADR-0056); пересмотр по росту истории — возможное партиционирование по ChatId/дате
Concurrencyоптимистическая блокировка на правке/удалении; выдача Seq — под unique-конкуренцией
Permissions / PIImessenger.chats.write (отправка); messenger.messages.edit (правка/удаление своего). PII: Body/Caption/PreviousBody/PreviousCaption. Sensitive: IpAddress/UserAgent — не в события/логи/ответы (кроме admin). История правок скрыта (кроме admin-арбитража)
Auditверсионирование + soft-delete; снимки правок MessageEditEntity — append-only факты арбитража

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • chat.md — родитель-агрегат, LastSeqCounter, ParticipantRole, статус чата.
  • deal.md, deal-document.md — источники событий EventCard (контракты — ../../crm/events/); исходящий контракт — ../events/message-posted.md.