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

UC-MSG-003. Правка сообщения в окне ≤ 60 секунд

Назначение

Описывает сценарий: автор правит (или удаляет) своё сообщение ленты чата (message.md) только в окне ≤ 60 с (EditableUntil = SentAt + 60s, INV-MSG-1), после чего сообщение иммутабельно. Каждая правка/удаление пишет скрытый append-only снимок прошлого контента (MessageEditEntity, INV-MSG-2) — история хранится, но не отдаётся ни одной read-моделью, кроме админ-арбитража Службы заботы. Захватываются IpAddress/UserAgent правки (Sensitive, INV-MSG-6). Полный алгоритм и маппинг — в API-методах: edit-message.md (PATCH), delete-message.md (DELETE, soft). Связан с контекстом messenger.

Metadata

ПолеЗначение
IDUC-MSG-003
Контекстmessenger (см. docs/06-services/messenger/)
Типосновной (+ A1 окно истекло, A2 удаление, A3 не автор / системная карточка, A4 правка в закрытом чате, A5 конкурентная правка)
Статусdraft

Актор

ПолеЗначение
РольАвтор сообщения (User: Инвестор/Агент/Менеджер)
Permissions / scopeJWT identity; messenger.messages.edit (правка/удаление своего); guest → 401
Tenantне применяется — доступ по членству чата (INV-CHAT-4); tenant наследуется от чата
Record-levelтолько автор: Message.SenderUserId == actor (INV-MSG-1); арбитраж истории — messenger.admin.view (Служба заботы)

Предусловие

  • Сообщение существует, актор — его автор и активный Participant чата (INV-CHAT-4); Kind != EventCard, IsSystem = false (системные карточки не правятся, INV-MSG-4).
  • Текущий момент в окне: now <= EditableUntil (≤ 60 с от SentAt).

Триггер

ПолеЗначение
ИсточникUI (контекстное меню сообщения «изменить»/«удалить», доступно только в окне) → API call
Триггерящий элементPATCH /api/messenger/chats/{chatId}/messages/{id} / DELETE /api/messenger/chats/{chatId}/messages/{id}

Основной Поток

Нить — правка текстового сообщения в окне. Полный алгоритм и маппинг — в edit-message.md; ниже — поток на уровне use-case.

  1. Отправка правки. Автор меняет текст сообщения и отправляет правку { ChatId, MessageId, Body }; автор правки берётся из токена (не из тела). Клиент передаёт IpAddress/UserAgent заголовками запроса.
  2. Проверка входа. Body1..8000 после Trim; сообщение существует и принадлежит указанному ChatId, иначе MSG_MESSAGE_NOT_FOUND.
  3. Гейт прав и окна. Автор правки — отправитель сообщения (Message.SenderUserId == actor, INV-MSG-1), иначе FORBIDDEN (A3); сообщение — не системная карточка (Kind != EventCard, IsSystem = false), иначе MSG_EDIT_FORBIDDEN (A3); текущий момент в окне (now <= EditableUntil), иначе MSG_EDIT_WINDOW_EXPIRED (A1); актор — активный участник чата (INV-CHAT-4).
  4. Переход. Сообщение переходит Sent/Edited → Edited (guard INV-MSG-1): пишется append-only снимок прошлого контента MessageEditEntity { Revision = ++n, PreviousBody, EditedAt, EditorUserId, IpAddress, UserAgent } (INV-MSG-2); Body заменяется новым, EditedAt = now, версия инкрементируется. Seq не меняется — порядок ленты стабилен.
  5. Что меняется. Обновляются Body/EditedAt/версия сообщения и добавляется снимок правки; конкурентная правка не должна затирать чужую (оптимистическая блокировка) — при конфликте ConcurrencyConflict.
  6. События. Поднимается доменное MessageEdited → строка realtime-outbox MessageEdited (persist-then-push). Интеграционных Kafka-событий правка не порождает (message-posted — только на создание сообщения).
  7. Согласованность. Сообщение, снимок правки и строка realtime-outbox фиксируются в одном согласованном изменении.
  8. Ответ и realtime. Возврат { MessageId, Seq, EditedAt }200; realtime-dispatch пушит message_edited в chat:{ChatId} — клиенты обновляют тело in-place; история правок наружу не отдаётся (INV-MSG-2).

Диаграмма

Альтернативные Потоки

A1. Окно правки истекло

  1. now > EditableUntil (прошло > 60 с от SentAt) → отказ MSG_EDIT_WINDOW_EXPIRED (409); сообщение immutable, снимок не пишется, событий нет.
  2. Клиент показывает «Время редактирования истекло», убирает пункт меню.

A2. Удаление в окне (soft)

  1. DELETE …/messages/{id} (delete-message.md): те же гейты (автор + окно + не системное). Сообщение переходит Sent/Edited → Deleted (soft): DeletedAt = now, тело скрывается (запись остаётся ради Seq), пишется снимок MessageEditEntity (PreviousBody/PreviousCaption).
  2. Доменное MessageDeleted → строка realtime-outbox MessageDeleted; 204 No Content. Клиенты показывают «сообщение удалено» плейсхолдером; Seq/порядок ленты сохраняются.

A3. Не автор или системная карточка

  1. Message.SenderUserId != actorFORBIDDEN (403); либо Kind == EventCard/IsSystem == true (авто-карточка сделки, INV-MSG-4) → MSG_EDIT_FORBIDDEN (409). Мутаций нет.

A4. Правка/удаление в закрытом чате сделки

  1. Чат сделки перешёл в Closed (сделка Completed/Cancelled, INV-CHAT-8). Закрытие запрещает новые сообщения пользователей (MSG_CHAT_CLOSED на отправке), но правка/удаление уже отправленного сообщения гейтится только окном (now <= EditableUntil) и авторством (INV-MSG-1) — статус чата на них не влияет: если сообщение отправлено в последние ≤ 60 с перед закрытием, автор ещё может его поправить/удалить в остатке окна. Вне окна — MSG_EDIT_WINDOW_EXPIRED (как A1).

A5. Конкурентная правка того же сообщения

  1. Две параллельные правки (или правка + удаление) одного сообщения → оптимистическая блокировка: применяется первая, вторая получает ConcurrencyConflict (409). Проигравший перечитывает актуальное состояние (возможно, уже Deleted или с обновлённым Body) и повторяет при необходимости в остатке окна. Двойного снимка MessageEditEntity на одну победившую правку не возникает.

Постусловие

  • В окне: Message.Body обновлён (или DeletedAt проставлен), EditedAt set, Version инкрементирован; добавлен скрытый MessageEditEntity (Revision, PreviousBody, IP/UA) — виден только админ-арбитражу.
  • Seq и порядок ленты не изменились; realtime message_edited/message_deleted доставлен участникам.
  • Вне окна / не автор / системное: состояние не изменилось.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_FAILED400Body вне 1..8000 / пустой после TrimПоказать ошибку формы
MSG_MESSAGE_NOT_FOUND404сообщение не найдено / ChatId не совпал«Сообщение не найдено»
MSG_EDIT_WINDOW_EXPIRED409now > EditableUntil (A1, INV-MSG-1)«Время редактирования истекло»
FORBIDDEN403SenderUserId != actor (A3, record-level)«Можно править только свои сообщения»
MSG_EDIT_FORBIDDEN409Kind = EventCard / IsSystem (A3, INV-MSG-4)Скрыть меню правки у карточек
MSG_CHAT_ACCESS_DENIED403нет активного членства в чате (INV-CHAT-4)«Нет доступа к чату»
MSG_ORIGIN_REQUIRED400отсутствуют IP/UA у user-правки (INV-MSG-6)Ретрай с корректными заголовками
ConcurrencyConflict409конкурентная правка (оптимистическая блокировка)Ретрай (перечитать сообщение)

Доменные События

СобытиеКогда поднимаетсяSubscribers
MessageEditedSent/Edited → Edited в окнеrealtime_outbox MessageEdited (push message_edited)
MessageDeletedSent/Edited → Deleted (A2)realtime_outbox MessageDeleted (push message_deleted)

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
правка/удаление не публикуют Kafka-событий (осознанно)

pin.messenger.message-posted.v1 порождается только созданием сообщения (UC-MSG-001); правка/удаление — локальный realtime без cross-service событий (message.md). История правок в Kafka/логи/ответы не попадает (Sensitive, только админ-арбитраж).

Acceptance Criteria

#КритерийПроверка
AC1Правка/удаление возможны только в окне ≤ 60 с; позже → MSG_EDIT_WINDOW_EXPIREDТест: правка на 59-й с → 200; на 61-й → 409, тело неизменно
AC2Каждая правка/удаление пишет MessageEditEntity (append-only, Revision, PreviousBody, IP/UA)Тест: 2 правки → 2 снимка Revision 1,2; история не в read-модели
AC3Править может только автор; системные карточки immutableТест A3: чужой → 403; EventCard409
AC4Seq/порядок ленты не меняются; удаление — soft-плейсхолдерТест A2: Seq стабилен, list-messages отдаёт «удалено»
AC5Правка/удаление realtime-пушат message_edited/message_deleted; Kafka-событий нетТест: realtime_outbox строка есть, Kafka-outbox пуст
AC6История правок видна только админ-арбитражу (get-chat-transcript)Тест: read-модель без истории; admin-эндпоинт — со снимками
AC7Дано чат сделки Closed, но сообщение в окне ≤ 60 с; когда автор правит; тогда правка проходит (закрытие блокирует новые сообщения, не правку в окне, A4)Тест: Closed-чат, правка на 30-й с → 200; новая отправка → MSG_CHAT_CLOSED
AC8Дано две параллельные правки одного сообщения; когда исполняются; тогда применяется одна, вторая → ConcurrencyConflict, один снимок правки (A5)Тест гонки: 409 проигравшему, один MessageEditEntity

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

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

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