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

Entity: TicketMessageEntity (child of Ticket)

Дом полей дочерней сущности «Сообщение тикета» контекста support — переписка по тикету (матрица 4.1 «диалог в рамках тикета») и материализация шаблонных ответов (4.3). Все проекции (тред-модель ответа, лента таймлайна тикета, счётчики) ссылаются сюда и описывают только дельту.

Назначение

TicketMessageEntity — одно сообщение переписки внутри тикета: обращение заявителя, ответ оператора Службы заботы или системная запись. Часть агрегата ticket.md: создаётся только методом Ticket.AddMessage, который в той же транзакции обновляет счётчики тикета (MessagesCount, LastMessageAt, FirstResponseAt). Хранится отдельной таблицей (отдельный дом по требованию брифа), но не является собственным aggregate root — инварианты и запись оркеструет родитель.

Сообщение может быть публичным (виден заявителю) или внутренней заметкой оператора (Internal, 4.1). Недопустимый контент снимается модерацией через soft-delete (3.7 в рамках тикета). Бинарные вложения лежат во внешнем storage/CDN; в support — только типизированные метаданные и FileId.

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

ПолеЗначение
Контекстsupport
KindEntity (в составе агрегата Ticket; не отдельный root — отдельная таблица)
Source of truthsupport (метаданные и текст); бинарь вложений — storage/CDN
Таблицаsupport.ticket_messages
Tenant isolationTenantId — дублирует Ticket.TenantId (фильтр без join к support.tickets)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор сообщения.
TicketIdGuidДаFK fk_ticket_messages_ticket_id → support.tickets(id) on delete cascade; index ix_ticket_messages_ticket_id_created_at (TicketId,CreatedAt)appРодительский тикет (ticket.md).
TenantIdGuidДаindexиз тикетаКопия Ticket.TenantId — фильтрация без join.
AuthorUserIdGuidДаapp / actorАвтор сообщения (заявитель, оператор или системный actor); заполняет аудит-поле создателя (CreatedByUserId).
AuthorKindTicketMessageAuthorKindДа.HasConversion<string>(), varchar(16)appРоль автора (см. enum).
VisibilityTicketMessageVisibilityДа.HasConversion<string>(), varchar(16), default PublicappPublic — виден заявителю; Internal — внутренняя заметка оператора.
BodystringДаvarchar(8000), не пустоеappТекст сообщения. Может содержать PII (не логируется целиком).
TemplateIdGuid?Нетлогическая ссылка на support.ticket_templates(id) (не FK)appЕсли сообщение сгенерировано из шаблонного ответа (4.3, ticket-template.md).
AttachmentsTicketMessageAttachments?Нетjsonb; типизированная модель ниже; ContractVersion = 1appМетаданные вложений; null/пусто — вложений нет.
CreatedAtDateTimeOffsetДачасть индекса ix_ticket_messages_ticket_id_created_atappМомент отправки (сортировка треда «старые → новые»).
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappКто редактировал (обычно автор).
DeletedAtDateTimeOffset?Нетsoft-delete filterappМодерация (3.7).
DeletedByUserIdGuid?НетappОператор/супервизор, снявший сообщение.

Enum TicketMessageAuthorKind

ЗначениеОписание
RequesterЗаявитель тикета (Ticket.RequesterUserId). Всегда пишет публично.
OperatorОператор Службы заботы (право support.tickets.manage). Может писать Public и Internal.
SystemСистемная запись (авто-ответ, эскалация, смена статуса). Публичность задаёт источник.

Enum TicketMessageVisibility

ЗначениеОписание
PublicВидно заявителю и операторам (тред переписки).
InternalВнутренняя заметка оператора; заявителю не отдаётся.

Value objects

Value objects не используются (метаданные вложений — через типизированный jsonb).

Модели JSONB (versioned)

Совместимость — additive-only; breaking-изменение → новый ContractVersion. Поля с ? — nullable.

TicketMessageAttachments (jsonb, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ItemsIReadOnlyList<TicketAttachment>Дапустой список = нет вложенийv1

TicketAttachment (элемент Items)

ПолеТипОбязательностьОграниченияSince version
FileIdGuidДассылка на бинарь в storage/CDNv1
FileNamestringДаvarchar(256)v1
ContentTypestringДаvarchar(128) (MIME)v1
SizeByteslongДа> 0 and <= 26_214_400 (25 МБ)v1

Связи

СвязьТипКлючПравило загрузкиDelete behavior
TicketEntityreference (родитель)TicketIdGetById(TicketId) в Ticket.AddMessage; лента — Filter(paging, buildQuery: TicketId, orderBy: CreatedAt asc)cascade (FK); тикеты обычно soft-delete — физический каскад крайний случай
TicketTemplateEntityreference (nullable)TemplateIdне грузится (логическая ссылка на источник текста)
Файл (storage/CDN)внешняяAttachments.Items[].FileIdпресайн-ссылка на скачивание (tenantId + fileId)— (регламент storage)

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-MSG-1Сообщение с AuthorKind = Requester обязано иметь Visibility = Public (заявитель не пишет внутренние заметки)при добавлении сообщенияSUPPORT_MESSAGE_VISIBILITY_INVALID
INV-MSG-2Visibility = Internal допустимо только при AuthorKind = Operatorпри добавлении сообщенияSUPPORT_MESSAGE_INTERNAL_OPERATOR_ONLY
INV-MSG-3Автор — участник тикета (Ticket.RequesterUserId) либо оператор с правом support.tickets.manageпри добавлении сообщения (проверка актора и участия)SUPPORT_MESSAGE_AUTHOR_FORBIDDEN
INV-MSG-4Нельзя добавить сообщение к терминальному (Closed) тикету без реопена (согласовано с INV-TKT-7)при добавлении сообщения (guard по статусу тикета)SUPPORT_TICKET_ALREADY_CLOSED
INV-MSG-5Первое Public-сообщение оператора выставляет Ticket.FirstResponseAt (INV-TKT-5); любое сообщение → MessagesCount++, LastMessageAt = now (в одном согласованном изменении)при добавлении сообщения

Конкурентность и согласованность записи

Само сообщение — append-only (собственной оптимистичной блокировки нет), но добавление идёт через родителя Ticket.AddMessage, который в том же согласованном изменении правит счётчики тикета (MessagesCount, LastMessageAt, при первом публичном ответе оператора — FirstResponseAt). Поэтому:

  • Параллельные ответы в один тикет сериализуются оптимистичной блокировкой родителя-агрегата: побеждает первый зафиксировавший, проигравший повторяет добавление на свежей версии тикета — оба сообщения в итоге попадают в тред, счётчик не теряет инкременты.
  • FirstResponseAt ставится один раз (INV-MSG-5 / INV-TKT-5): при одновременных первых публичных ответах двух операторов метку проставляет только выигравший переход; повторная установка не перезаписывает уже проставленное значение (SLA-факт стабилен).
  • Guard по статусу тикета (INV-MSG-4): если тикет к моменту фиксации перешёл в Closed, добавление отклоняется SUPPORT_TICKET_ALREADY_CLOSED — сообщение к терминальному тикету без реопена не пишется.
  • Вложения: каждый элемент Attachments.ItemsSizeBytes ∈ (0, 25 МБ]; при нарушении размера/типа вложение отклоняется валидацией (VALIDATION_FAILED) до сохранения сообщения; бинарь лежит вне БД (storage/CDN), в support — только метаданные.

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

СобытиеТипКогдаSnapshot/поля
TicketMessageAddedдоменноедобавление сообщенияTicketId, MessageId, AuthorKind, Visibility — обновляет счётчики/таймлайн тикета в одном согласованном изменении
TicketMessagePostedKafkaEventинтеграционное, topic pin.support.ticket-message-posted.v1, ContractVersion = 1публичный ответ оператора (reply-ticket, Visibility = Public)TicketId, Number, TenantId, MessageId, RequesterUserId, AuthorUserId, AuthorKind, Visibility, HasAttachments, PostedAt; потребитель notifications уведомляет заявителя; контракт — ../events/ticket-message-posted.md

Сама дочерняя сущность Kafka не публикует, но добавление публичного ответа оператором (reply-ticket) поднимает доменное TicketMessageAdded, по которому агрегат-родитель публикует интеграционное pin.support.ticket-message-posted.v1 в одном согласованном изменении — потребитель notifications уведомляет заявителя о новом ответе (ticket-message-posted.md, reply-ticket.md). Внутренние заметки (Internal) и сообщения самого заявителя (Requester) этого события не порождают; возврат тикета в работу при ответе заявителя несёт pin.support.ticket-status-changed.v1 (родитель ticket.md).

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

АспектЗначение
ИндексыPK; ix_ticket_messages_ticket_id_created_at (ticket_id,created_at); partial ix_ticket_messages_public (ticket_id,created_at) where visibility = 'Public' (быстрый public-тред)
Партиционирование / retentionnone; бинарь вложений — вне БД (storage/CDN)
Concurrencyнет (append-only + модерация soft-delete)
Permissions / PIIПубличный заявитель видит только Public-сообщения своего тикета; support.tickets.view — все, включая Internal. PII: Body и FileName могут содержать персональные данные — в логи не пишется целиком (Id/усечённо).
Auditappend-only; стамп автора/редактора (кто и когда); модерация — soft-delete (иммутабельный факт снятия)

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

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

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

  • ticket.md — родительский агрегат, Ticket.AddMessage, счётчики и INV-TKT-5; ticket-template.md — источник шаблонных ответов (TemplateId, 4.3).
  • reply-ticket — операция ответа оператора и уведомления заявителя; событие переписки — ticket-message-posted.md; сценарий — UC-SUP-002.