POST /api/messenger/chats/{chatId}/messages/attachment — Отправить вложение
Назначение
Участник отправляет сообщение-вложение (Kind = Attachment). Бинарь файла в messenger не хранится
(INV-MSG-7): клиент предварительно загружает файл в storage/CDN по пресайн-URL (вне этой операции) и присылает
только FileId + метаданные (FileName/ContentType/SizeBytes + опциональная Caption). Как и для
текста: атомарно выдаётся per-chat Seq, захватываются IpAddress/UserAgent (INV-MSG-6), публикуется
MessagePostedEvent и уходит realtime-push. Дом полей — message.md.
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | messages |
| Feature group | messages |
| Статус | draft |
| Документ | docs/06-services/messenger/api/messages/send-attachment-message.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Инвестор / Агент / Руководитель / Менеджер застройщика / Менеджер ПИН) |
| Auth policy / Permissions | authenticated (JWT) + permission messenger.chats.write |
| Scope (RBAC) | не применяется: доступ по членству (ADR-0055/0052) |
| Record-level | активный ParticipantEntity (ChatId,UserId = actor, RemovedAt is null) — INV-CHAT-4 |
| Tenant isolation | не tenanted: чат кросс-сторонний; изоляция — членство (chat.md) |
| Audit | create (MessageEntity); SenderUserId = actor; IpAddress/UserAgent/FileId/FileName в логи не пишутся |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/messenger/chats/{chatId}/messages/attachment |
| Success status | 201 (сообщение-вложение создано) |
| Idempotency header | Idempotency-Key (= ClientMessageId, Guid) — дедуп двойной отправки при retry/reconnect |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ChatId | route | Guid | Да | != Guid.Empty; чат существует; actor — активный участник; Status != Closed; политика (см. Алгоритм) | — |
FileId | body | Guid | Да | != Guid.Empty; логическая ссылка на storage/CDN (бинарь не в БД, INV-MSG-7) | Internal: в логи не пишется |
FileName | body | string | Да | 1..256, не пустое после trim | может нести детали — не логируется |
ContentType | body | string | Да | 1..128; допустимый MIME (конфигурация) → AttachmentKind (см. Алгоритм) | — |
SizeBytes | body | long | Да | > 0 и <= <limit> (конфигурация) | защита от unbounded |
Caption | body | string? | Нет | <= 2000 после trim | PII: подпись в логи не пишется |
ClientMessageId | header Idempotency-Key | Guid | Да | != Guid.Empty; ключ идемпотентности | дедуп ретраев |
UserId | identity claims | Guid | Да | Required; SenderUserId, предикат членства | scope isolation |
IpAddress | server (remote/XFF) | IpAddress | Да | захват сервером (INV-MSG-6); не из тела | Sensitive/internal |
UserAgent | header User-Agent | string | Да | <= 512; захват сервером (INV-MSG-6) | Sensitive/internal |
Модель ответа MessageModel
Union-проекция (как в list-messages.md); для Attachment непуста под-модель Attachment.
DownloadUrl — короткоживущий presign у storage-адаптера (не логируется). IP/UA/история правок не входят.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | MessageEntity.Id | — | Идентификатор сообщения. |
Seq | long | Да | per-chat порядковый | — | Выданный порядковый. |
Kind | string (MessageKind) | Да | Kind | — | Всегда Attachment. |
SenderUserId | Guid | Да | SenderUserId | — | Автор (= actor). |
SenderRole | string (ParticipantRole) | Да | SenderRole | — | Роль автора на момент отправки. |
SentAt | DateTimeOffset | Да | SentAt | — | Момент отправки. |
EditableUntil | DateTimeOffset | Да | SentAt + 60s | — | Окно правки подписи/удаления (INV-MSG-1). |
Attachment | MessageAttachmentModel | Да | AttachmentMessageEntity | — | { FileId, FileName, ContentType, SizeBytes, Caption?, AttachmentKind, DownloadUrl }. |
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь-участник; требуется право
messenger.chats.write
(иначе — FORBIDDEN; без JWT — UNAUTHORIZED). ChatId берётся из маршрута; метаданные вложения — из
тела; ClientMessageId — из Idempotency-Key; IpAddress/UserAgent захватываются сервером. По
ClientMessageId проверяется идемпотентность: если запрос уже обработан — вернуть сохранённое сообщение.
- Проверка входа и предусловий.
FileId непуст, FileName 1..256, ContentType 1..128 из белого
списка, SizeBytes > 0 && <= limit, Caption <= 2000, ClientMessageId непуст, IP/UA заданы
(INV-MSG-6/7) — иначе VALIDATION_FAILED/MSG_ATTACHMENT_INVALID. Актор должен быть активным участником —
иначе MSG_CHAT_ACCESS_DENIED. Чат не Closed — иначе MSG_CHAT_CLOSED. Коммуникационная политика для
Direct-чата: ни одна сторона не в чёрном списке (INV-CHAT-3) — иначе MSG_COMMUNICATION_BLOCKED.
- Что читаем. Чат — для монотонной выдачи
Seq (сериализация конкурентных отправок, INV-CHAT-5/
INV-MSG-3) и источника роли участника. Наличие файла в storage синхронно не проверяется (граница storage,
INV-MSG-7); валидность presign — при скачивании.
- Бизнес-правила и маппинг.
AttachmentKind определяется из ContentType (Image/Document/Audio/
Video/Other). Создаётся сообщение-вложение: SenderUserId = actor, SenderRole — роль участника,
IsSystem = false, метаданные файла, SentAt = now, EditableUntil = now + 60s, IpAddress/UserAgent.
Чат выдаёт Seq (per-chat монотонный) и обновляет денормализацию последнего сообщения;
LastMessagePreview = FileName/иконка типа (без тела вложения).
- Что меняется. Появляется сообщение, чат обновляет денормализацию и счётчик
Seq. Всё фиксируется в
одном согласованном изменении с публикацией события, намерением realtime-доставки и отметкой
идемпотентности; конфликт выдачи Seq → MSG_SEQ_CONFLICT (авто-retry).
- Факты и события. Публикуется интеграционное событие
MessagePostedEvent в топик
pin.messenger.message-posted.v1 (ContractVersion = 1, ключ ChatId, payload {ChatId, MessageId, Seq, Kind = Attachment, SenderUserId, SenderRole, SentAt, Preview = FileName/тип} — без IP/UA/бинаря;
консюмер notifications, контракт message-posted.md). Плюс намерение
realtime-доставки для realtime-dispatch (persist-then-push → хаб MessageReceived, группа
chat:{ChatId}).
- Результат.
MessageModel (Kind = Attachment, Seq, Attachment с presign DownloadUrl), 201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | MessagePostedEvent → pin.messenger.message-posted.v1 (key ChatId, консюмер notifications) |
| Внешние вызовы | storage/CDN presign download-URL при маппинге ответа (бинарь не грузится сервисом; TTL короткий) |
| Проекции / поиск | денорм last-message на ChatEntity (LastMessageId/Seq/At/Preview) |
| Уведомления | in-app realtime MessageReceived; offline push/email — notifications как консюмер события |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | пустые ChatId/ClientMessageId, нет IP/UA | «Проверьте параметры вложения» | no retry |
MSG_ATTACHMENT_INVALID | 400 | пустой FileId, недопустимый ContentType, SizeBytes вне пределов, пустой FileName (INV-MSG-7) | «Недопустимое вложение (тип/размер)» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
FORBIDDEN | 403 | нет permission messenger.chats.write | «Недостаточно прав» | no retry |
MSG_CHAT_ACCESS_DENIED | 403 | actor — не активный участник (или чат не существует) | «Нет доступа к чату» | no retry |
MSG_COMMUNICATION_BLOCKED | 409 | чёрный список любой из сторон (INV-CHAT-3) | «Отправка недоступна» | no retry |
MSG_CHAT_CLOSED | 409 | чат Closed (INV-CHAT-8) | «Чат закрыт» | no retry |
MSG_SEQ_CONFLICT | 409 | гонка выдачи Seq (INV-MSG-3) | «Повторите отправку» | retry (авто) |
Acceptance Criteria
- Отправка вложения. Дано активный участник, чат не
Closed, валидные метаданные (FileId,
FileName 1..256, ContentType из белого списка, SizeBytes в пределах). Когда POST. Тогда создаётся
сообщение Kind = Attachment, выдаётся per-chat Seq, AttachmentKind выведен из ContentType,
публикуется MessagePostedEvent в pin.messenger.message-posted.v1 (ключ ChatId, без бинаря/IP/UA), push
MessageReceived, ответ 201 c presign DownloadUrl.
- Идемпотентный повтор. Дано тот же
Idempotency-Key (ClientMessageId). Когда retry/reconnect. Тогда
возвращается ранее сохранённое сообщение, дубля нет.
- Недопустимое вложение. Дано
ContentType вне белого списка, SizeBytes вне пределов, пустой FileId
или FileName. Когда POST. Тогда MSG_ATTACHMENT_INVALID (400) (INV-MSG-7).
- Чёрный список (Direct). Дано
Direct-чат и одна из сторон в чёрном списке. Когда POST. Тогда
MSG_COMMUNICATION_BLOCKED (409) (INV-CHAT-3).
- Закрытый чат. Дано чат
Closed. Когда POST. Тогда MSG_CHAT_CLOSED (409) (INV-CHAT-8).
- Не участник. Дано актор не активный участник. Когда
POST. Тогда MSG_CHAT_ACCESS_DENIED (403).
- Гонка выдачи
Seq. Дано конкурентные отправки в один чат. Когда Seq конфликтует. Тогда
MSG_SEQ_CONFLICT (409) с авто-retry; итоговые Seq монотонны и уникальны (INV-MSG-3).
- Бинарь вне сервиса. Дано
messenger не хранит файл (INV-MSG-7). Когда отправка. Тогда наличие файла в
storage синхронно не проверяется; валидность presign проверяется при скачивании.
- Приватность.
FileId/FileName/Caption/IP/UA не логируются и не входят в событие.
Совместимость и наблюдаемость
- Новые optional поля модели/payload — additive; удаление/переименование/смена типа или enum
(
AttachmentType) — breaking (migration + rollout; событие .v2 + dual-publish). Список ContentType и
лимит SizeBytes — конфигурация (расширение additive).
- Logs:
attachment-message-posted (ChatId, MessageId, Seq, SenderUserId, AttachmentKind,
SizeBytes; FileId/FileName/Caption/IP/UA не пишутся); metrics:
messenger_messages_posted_total{kind=attachment,result}, persist p95 < 300 мс (SLO); traces через
traceparent; dashboard pin-messenger.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Сущности: message.md (
Attachment, AttachmentType, INV-MSG-1/3/6/7),
chat.md (выдача Seq, INV-CHAT-3/5/8), participant.md,
blacklist.md.
- Событие: message-posted.md.
- Соседние: send-text-message.md, edit-message.md,
delete-message.md, list-messages.md; README — ../../README.md.
- ADR: 0055,
0056.