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

PATCH /api/messenger/chats/{chatId}/messages/{messageId} — Правка сообщения (≤ 1 мин)

Назначение

Автор правит своё сообщение в окне EditableUntil (≤ 60 с от отправки, INV-MSG-1): для Text меняется Body, для AttachmentCaption (сам файл/FileId не меняется). Каждая правка пишет append-only снимок прошлого контента MessageEditEntity (история правок; читателям не показывается — INV-MSG-2, кроме admin-арбитража) и выставляет EditedAt. Карточки (EventCard) и системные сообщения править нельзя (INV-MSG-4). Участникам чата уходит realtime-push MessageEdited. Дом полей — message.md.

Metadata

ПолеЗначение
Сервис/контекстmessenger
API areamessages
Feature groupmessages
Статусdraft
Документdocs/06-services/messenger/api/messages/edit-message.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (автор сообщения)
Auth policy / Permissionsauthenticated (JWT) + permission messenger.messages.edit
Scope (RBAC)не применяется: доступ по членству + авторству (ADR-0055/0052)
Record-levelавтор: message.SenderUserId = actor.UserId (INV-MSG-1) + активный Participant
Tenant isolationне tenanted: изоляция — членство + авторство (chat.md)
Auditupdate (MessageEntity) + append MessageEditEntity (снимок прошлого, EditorUserId = actor); IP/UA правки — Sensitive/internal

HTTP-контракт

ПолеЗначение
MethodPATCH
Route/api/messenger/chats/{chatId}/messages/{messageId}
Success status200 (правка применена)
Idempotency headerне применяется (правка повторяема; окно ≤ 60 с, конкуренция — оптимистическая блокировка)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ChatIdrouteGuidДа!= Guid.Empty; чат существует; actor — активный участник
MessageIdrouteGuidДа!= Guid.Empty; сообщение существует, ChatId совпадает; SenderUserId = actor; now <= EditableUntil; Kind != EventCard (см. Алгоритм)
Bodybodystring?Условнообязателен и только для Kind = Text; 1..8000 после trimPII: не логируется
Captionbodystring?Условнотолько для Kind = Attachment; <= 2000 после trim (null/пусто — снять подпись)PII: не логируется
UserIdidentity claimsGuidДаRequired; предикат членства + авторстваscope isolation
IpAddressserver (remote/XFF)IpAddressДазахват сервером; снимок правкиSensitive/internal
UserAgentheader User-AgentstringДа<= 512; захват серверомSensitive/internal

Правится ровно одно поле по типу сообщения: Text → Body, Attachment → Caption; несовпадение поля и KindVALIDATION_FAILED.

Модель ответа MessageModel

Union-проекция обновлённого сообщения (как в list-messages.md); EditedAt выставлен. История правок и IP/UA в ответ не входят (INV-MSG-2).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаMessageEntity.IdИдентификатор сообщения.
SeqlongДаSeqПорядок в ленте (не меняется).
Kindstring (MessageKind)ДаKindText / Attachment.
EditedAtDateTimeOffsetДаEditedAtМомент этой правки (флаг «изменено»).
EditableUntilDateTimeOffsetДаEditableUntilНе сдвигается правкой (окно от SentAt).
TextMessageTextModel?НетTextMessageEntity{ Body } — для Text.
AttachmentMessageAttachmentModel?НетAttachmentMessageEntityОбновлённая Caption (+ прежние FileId/метаданные).

Алгоритм

  1. Контекст и доступ. Операцию выполняет пользователь-автор; требуется право messenger.messages.edit (иначе — FORBIDDEN; без JWT — UNAUTHORIZED). ChatId/MessageId берутся из маршрута, Body?/ Caption? — из тела; IpAddress/UserAgent захватываются сервером.
  2. Проверка входа и предусловий. ChatId/MessageId непусты; для TextBody 1..8000, Caption пуст; для AttachmentCaption <= 2000, Body пуст — иначе VALIDATION_FAILED. Актор должен быть активным участником чата — иначе MSG_CHAT_ACCESS_DENIED. Сообщение должно существовать и принадлежать этому чату — иначе MSG_MESSAGE_NOT_FOUND. Автор, тип и окно (INV-MSG-1/4): SenderUserId == actor иначе MSG_EDIT_FORBIDDEN; сообщение не EventCard/системное иначе MSG_EDIT_FORBIDDEN; now <= EditableUntil иначе MSG_EDIT_WINDOW_EXPIRED.
  3. Что читаем. Сообщение (для проверок и снимка); при правке последнего сообщения чата дополнительно сам чат — для денормализации LastMessagePreview.
  4. Бизнес-правила и переход. Рассчитать Revision = (max prior)+1; записать снимок прошлого контента в MessageEditEntity (PreviousBody для Text / PreviousCaption для Attachment, EditorUserId = actor, EditedAt = now, IpAddress/UserAgent); применить новое значение (Body/Caption); EditedAt = now. Переход состояния Sent/Edited → Edited (INV-MSG-1). Если правится последнее сообщение чата — обновить LastMessagePreview (ограничено ≤ 256).
  5. Что меняется. Обновляется сообщение + добавляется снимок MessageEditEntity (+ обновляется чат при денормализации). Всё фиксируется в одном согласованном изменении (INV-MSG-2); оптимистический конфликт → MSG_EDIT_CONFLICT.
  6. Факты и события. Интеграционного Kafka-события нет (правка приватна, в notifications не уходит — INV-MSG-2). Записывается только намерение realtime-доставки для realtime-dispatch (persist-then-push → хаб MessageEdited, группа chat:{ChatId}, нагрузка {ChatId, MessageId, EditedAt, Body?/Caption?} — тело только участникам).
  7. Результат. Обновлённое сообщение MessageModel (EditedAt выставлен), 200.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событиянет (правка в Kafka/notifications не публикуется — INV-MSG-2/приватность)
Внешние вызовынет (для Attachment меняется только Caption; FileId/бинарь не трогаются)
Проекции / поискденорм LastMessagePreview на ChatEntity (если правится последнее сообщение)
Уведомленияin-app realtime MessageEdited (только участникам); offline — нет

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400поле не соответствует Kind, длина вне пределов, оба/ни одного поля«Проверьте текст правки»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
FORBIDDEN403нет permission messenger.messages.edit«Недостаточно прав»no retry
MSG_CHAT_ACCESS_DENIED403actor — не активный участник«Нет доступа к чату»no retry
MSG_EDIT_FORBIDDEN403не автор, или EventCard/системное (INV-MSG-1/4)«Править можно только своё сообщение»no retry
MSG_MESSAGE_NOT_FOUND404сообщение не найдено или ChatId не совпадает«Сообщение не найдено»no retry
MSG_EDIT_WINDOW_EXPIRED409now > EditableUntil (окно ≤ 60 с истекло)«Время для правки истекло»no retry
MSG_EDIT_CONFLICT409оптимистический конфликт«Сообщение изменилось, повторите»retry (перечитать)

Acceptance Criteria

  • Правка текста в окне. Дано своё Text-сообщение, now <= EditableUntil, Body в 1..8000. Когда PATCH. Тогда Body заменён, EditedAt = now, записан снимок MessageEditEntity (append-only, INV-MSG-2), переход Sent/Edited → Edited, push MessageEdited, ответ 200; Seq и EditableUntil не меняются.
  • Правка подписи вложения. Дано Attachment-сообщение. Когда PATCH c Caption <= 2000. Тогда обновляется только Caption; FileId/бинарь не трогаются.
  • Снятие подписи. Дано Attachment c подписью. Когда Caption = null/пусто. Тогда подпись снимается.
  • Поле не соответствует Kind. Дано Text c Caption (или Attachment c Body), либо оба/ни одного поля. Когда PATCH. Тогда VALIDATION_FAILED (400).
  • Окно истекло. Дано now > EditableUntil. Когда PATCH. Тогда MSG_EDIT_WINDOW_EXPIRED (409).
  • Не автор / карточка / системное. Дано SenderUserId != actor или Kind == EventCard/системное (INV-MSG-4). Когда PATCH. Тогда MSG_EDIT_FORBIDDEN (403).
  • Не участник / не найдено. Дано актор не активный участник → MSG_CHAT_ACCESS_DENIED (403); сообщение не найдено/ChatId не совпал → MSG_MESSAGE_NOT_FOUND (404).
  • Конкурентная правка. Дано две параллельные правки одного сообщения. Когда обе доходят. Тогда проигравший получает MSG_EDIT_CONFLICT (409) и перечитывает.
  • Приватность. Kafka-события/уведомления нет (INV-MSG-2); Body/Caption/PreviousBody/IP/UA не логируются; тело правки уходит только участникам чата.

Совместимость и наблюдаемость

  • Модель ответа — та же union MessageModel; изменения — additive без breaking. Окно правки (60 с) — доменная константа EditableUntil (изменение — доменное решение, не API-контракт).
  • Logs: message-edited (ChatId, MessageId, Revision, EditorUserId; Body/Caption/PreviousBody/ IP/UA не пишутся); metrics: messenger_messages_edited_total{result}, latency p95 < 300 мс; traces через traceparent; dashboard pin-messenger.

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

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

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