POST /api/messenger/blacklist — Заблокировать пользователя
Добавить пользователя в чёрный список актора: запрет писать и звонить обеим сторонам, пока запись
существует. Материализует агрегат BlacklistEntity (направленная запись
Owner → Blocked); эффект для доменного сервиса CommunicationPolicy —
взаимный deny (INV-BL-3). Владелец — контекст messenger, feature group blacklist. Блокировка
приватна: заблокированному не раскрывается.
Назначение
Принять от авторизованного пользователя команду блокировки собеседника (с необязательной приватной причиной)
и завести запись чёрного списка; идемпотентно при повторе (INV-BL-1).
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | public |
| Feature group | blacklist |
| Статус | draft |
| Документ | docs/06-services/messenger/api/blacklist/block-user.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User |
| Auth policy / Permissions | messenger.blacklist.manage (JWT) |
| Scope (RBAC) | платформа (person-level, вне tenant, ADR-0052) |
| Record-level | OwnerUserId = actor — владелец создаваемой записи (форсится сервером) |
| Tenant isolation | Запрос не tenanted (blacklist — вне tenant) |
| Audit | create (CreatedByUserId = OwnerUserId, инвариант «создатель = владелец»); Reason в логи не пишется |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/messenger/blacklist |
| Success status | 201 (создано) / 200 (идемпотентный повтор существующей блокировки) |
| Idempotency header | не применяется (бизнес-идемпотентность по паре Owner,Blocked, шаг 4) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
OwnerUserId | identity claims | Guid | Да | Required (= actor); из тела не принимается | Record-level (own-only) |
BlockedUserId | body | Guid | Да | != Guid.Empty; != OwnerUserId (INV-BL-1); существует в реплике (INV-BL-6) | — |
Reason | body | string? | Нет | 0..256 (varchar(256)) | приватная заметка; в логи не пишется |
Модель ответа BlockUserResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
BlacklistId | Guid | Да | BlacklistEntity.Id | — | Идентификатор записи блокировки. |
BlockedUserId | Guid | Да | BlacklistEntity.BlockedUserId | — | Кого заблокировали. |
CreatedAt | DateTimeOffset | Да | BlacklistEntity.CreatedAt | — | Момент блокировки. |
AlreadyBlocked | bool | Да | computed (шаг 4) | — | true — идемпотентный повтор (запись уже была). |
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный пользователь (
User); OwnerUserId = актор
(из тела не принимается); требуется право messenger.blacklist.manage (иначе MESSENGER_BLACKLIST_FORBIDDEN).
- Проверка входа.
BlockedUserId непустой и != OwnerUserId (иначе MESSENGER_BLACKLIST_SELF,
INV-BL-1); Reason — не длиннее 256 символов, иначе ошибка валидации.
- Проверки предусловий. Блокируемый пользователь существует в реплике (
INV-BL-6), иначе
MESSENGER_BLACKLIST_USER_NOT_FOUND. Проверить, нет ли уже записи блокировки пары Owner → Blocked.
- Идемпотентность и создание. Если запись пары уже есть — идемпотентный возврат: существующая запись
с
AlreadyBlocked = true, без изменений. Иначе создать запись BlacklistEntity (Owner → Blocked,
Reason, CreatedByUserId = OwnerUserId). Эффект: для пары CommunicationPolicy.CanMessage/CanCall
становится deny (INV-BL-3); активный контакт пары (contact.md) при этом
не удаляется (INV-BL-2).
- Реалтайм-уведомление. В той же согласованной операции только инициатору (
Owner) уходит realtime-
сигнал BlacklistChanged через SignalR-hub (UI прячет кнопки «написать»/«позвонить»). Заблокированному
ничего не отправляется. Интеграционных Kafka-событий нет — блокировка приватна, кросс-сервисных
потребителей нет.
- Согласованность. Запись блокировки и реалтайм-эффект фиксируются атомарно; гонку одновременных
BlockUser закрывает уникальный индекс ux_blacklist_owner_blocked (конфликт вставки →
MESSENGER_BLACKLIST_CONFLICT, при повторе — идемпотентная обработка как в шаге 4).
- Результат.
BlockUserResult → HTTP 201 (создано) либо 200 при AlreadyBlocked = true.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (blacklist приватен; Kafka/outbox не используется) |
| External calls | none |
| Cache / projections / search | CommunicationPolicy для пары становится deny (вычисляется на чтении; кэша нет) |
| Notifications | realtime BlacklistChanged в SignalR-hub — только Owner; заблокированному — ничего |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | пустой/некорректный BlockedUserId, Reason > 256 | «Проверьте данные блокировки» | no retry |
MESSENGER_BLACKLIST_SELF | 400 | BlockedUserId == OwnerUserId (INV-BL-1) | «Нельзя заблокировать себя» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
MESSENGER_BLACKLIST_FORBIDDEN | 403 | нет permission messenger.blacklist.manage | «Недостаточно прав» | no retry |
MESSENGER_BLACKLIST_USER_NOT_FOUND | 404 | BlockedUserId нет в реплике (INV-BL-6) | «Пользователь не найден» | no retry |
MESSENGER_BLACKLIST_CONFLICT | 409 | гонка вставки (unique-нарушение) | «Повторите попытку» | retry (перечитка → идемпотентный ответ) |
Совместимость и наблюдаемость
- Новые optional поля тела/ответа — additive; удаление/переименование/смена типа — breaking
(migration + rollout).
Reason — приватная заметка, наружу заблокированному не отдаётся.
- Logs:
user blocked (OwnerUserId, BlockedUserId, AlreadyBlocked; Reason/ФИО не пишутся);
metrics: messenger_blacklist_block_total{result}, latency p95; traces — сквозной trace (traceparent);
dashboard pin-messenger.
Acceptance Criteria
- Happy path. Given авторизованный
Owner с messenger.blacklist.manage, существующий Blocked вне
списка; When POST {blockedUserId, reason?}; Then 201, запись Owner → Blocked, CommunicationPolicy
пары → deny обеим сторонам (INV-BL-3), realtime BlacklistChanged только Owner,
AlreadyBlocked = false.
- Идемпотентный повтор. Given запись пары уже есть; When повторный
POST; Then 200,
AlreadyBlocked = true, новой строки и события нет (INV-BL-1).
- Нельзя себя. Given
BlockedUserId == OwnerUserId; When POST; Then 400 MESSENGER_BLACKLIST_SELF
(INV-BL-1).
- Нет пользователя. Given
BlockedUserId отсутствует в реплике; When POST; Then
404 MESSENGER_BLACKLIST_USER_NOT_FOUND (INV-BL-6).
- Гонка вставки. Given два параллельных
Block на пару; When оба POST; Then одна строка
(ux_blacklist_owner_blocked), проигравший → 409 MESSENGER_BLACKLIST_CONFLICT → перечитка →
идемпотентный ответ.
- Контакт не рвётся. Given
Accepted-контакт пары; When POST; Then контакт не удалён (INV-BL-2),
но CanMessage/CanCall → deny.
- Приватность/PII. На любом исходе заблокированному realtime не уходит;
Reason/ФИО в логи не пишутся.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы