Entity / Aggregate: BlacklistEntity
Дом полей агрегата «Запись чёрного списка» контекста messenger (контакты, звонки, чёрный список;
ADR-0055). Все проекции (список заблокированных в
UI, вычисление CommunicationPolicy, отклонение отправки сообщения/звонка) ссылаются сюда и описывают только
дельту, а не переописывают поля.
Назначение
BlacklistEntity — направленная запись блокировки «Owner заблокировал Blocked»: один пользователь
запрещает другому писать и звонить себе. Модель — простой агрегат-факт: строка либо существует (блок
активен), либо нет (разблокировано, строка удалена). Отдельного Status-поля нет.
Эффект для доменного сервиса CommunicationPolicy — взаимный запрет: если любая из сторон заблокировала
другую, общение недоступно обеим (ни писать, ни звонить), пока хотя бы одна запись существует. Сам факт
блокировки виден только инициатору (Owner); заблокированному он не раскрывается — его сообщения тихо
отклоняются на отправке с общей ошибкой (см. Безопасность).
Классификация
| Поле | Значение |
|---|---|
| Контекст | messenger |
| Kind | AggregateRoot (простой агрегат-факт) |
| Source of truth | messenger |
| Таблица | messenger.blacklist_entries |
| Tenant isolation | Нет: блокировка — отношение пользователь↔пользователь, вне компаний/tenant. Изоляция запросов — по OwnerUserId = actor (record-level, см. Безопасность). |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор записи блокировки. |
OwnerUserId | Guid | Да | index ix_blacklist_owner (в составе ux_blacklist_owner_blocked); логическая ссылка на identity.users(id) | app (actor) | Кто заблокировал (владелец записи). |
BlockedUserId | Guid | Да | index ix_blacklist_blocked; часть ux_blacklist_owner_blocked; логическая ссылка на identity.users(id) | app | Кого заблокировали. |
Reason | string? | Нет | varchar(256) | app (форма блокировки) | Необязательная причина (личная заметка инициатора; заблокированному не показывается). |
CreatedAt | DateTimeOffset | Да | index ix_blacklist_created_at desc | app | Момент блокировки. |
CreatedByUserId | Guid | Да | = OwnerUserId (check ck_blacklist_created_by_is_owner) | app (actor) | Автор блокировки. Совпадает с OwnerUserId. |
Constraint уникальности: ux_blacklist_owner_blocked unique on (OwnerUserId, BlockedUserId) — не более
одной активной блокировки на направленную пару (INV-BL-1). Check ck_blacklist_not_self:
OwnerUserId <> BlockedUserId (INV-BL-1, нельзя блокировать себя).
Value objects
Value objects не используются.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
UserEntity (identity, владелец) | replica-ссылка (логическая) | OwnerUserId | по actor; кросс-контекстного FK нет | — |
UserEntity (identity, заблокированный) | replica-ссылка (логическая) | BlockedUserId | по паре (OwnerUserId, BlockedUserId) | — |
ContactEntity (messenger) | reference (не владеет) | пара (OwnerUserId,BlockedUserId) | в CommunicationPolicy | не каскад: активный контакт пары при блокировке не удаляется (INV-BL-2) |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-BL-1 | На направленную пару (OwnerUserId,BlockedUserId) — не более одной записи; нельзя блокировать себя | ux_blacklist_owner_blocked + ck_blacklist_not_self; повторная блокировка идемпотентна (возвращает существующую) | MSG_BLACKLIST_SELF (самоблокировка); дубль — идемпотентный no-op |
| INV-BL-2 | При блокировке активный ContactEntity пары не удаляется автоматически, но CommunicationPolicy отдаёт deny (сообщения и звонки запрещены обеим сторонам, пока блок существует); UI скрывает кнопки «написать»/«позвонить» | доменный сервис CommunicationPolicy (см. ниже); клиент — по флагам проекции контакта | — |
| INV-BL-3 | CanMessage(a,b) = ¬∃ blacklist(a→b) ∧ ¬∃ blacklist(b→a); CanCall(a,b) = CanMessage(a,b) ∧ ∃ Contact(a,b).Status = Accepted | CommunicationPolicy при отправке сообщения и при сигналинге звонка | send: MSG_COMMUNICATION_BLOCKED (общая) |
| INV-BL-4 | Разблокировка (UnblockUser) — удаление записи (hard delete допустим; след — в аудите операции); восстанавливает политику по существующему контакту | операция UnblockUser; отсутствие строки — идемпотентный no-op | — |
Проверка «кто меня заблокировал» (обратное направление INV-BL-3) выполняется по индексу
ix_blacklist_blocked (BlockedUserId).
Доменный сервис CommunicationPolicy
CommunicationPolicy — читающий доменный сервис (не сущность; его «дом» — communication-policy.md).
Для пары (a, b):
CanMessage(a, b) = ¬ exists BlacklistEntity(Owner=a, Blocked=b)
∧ ¬ exists BlacklistEntity(Owner=b, Blocked=a)
CanCall(a, b) = CanMessage(a, b) ∧ exists ContactEntity(a, b).Status = Accepted
Взаимность обеспечивается дизъюнкцией по обоим направлениям: одной записи достаточно, чтобы запретить
общение в обе стороны. Звонки (CanCall) — отдельная фаза мессенджера (ADR-0055): формула фиксирует
контракт для сигналинга поверх контактов.
Диаграмма отражает жизненный цикл записи (Status-поля нет; состояние = наличие строки).
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
UserBlocked | доменное | Создание записи (BlockUser) | Id, OwnerUserId, BlockedUserId |
UserUnblocked | доменное | Удаление записи (UnblockUser) | OwnerUserId, BlockedUserId |
Оба события — доменные, обрабатываются in-process в той же транзакции, что и мутация. Их обработчик пушит
realtime-уведомление BlacklistChanged через SignalR-hub только инициатору (Owner), чтобы UI обновил
список и скрыл/показал кнопки общения. Заблокированному ничего не отправляется (приватность).
При блокировке realtime-подписки чата собеседника не рвутся (соединение живёт), но новые сообщения пары
отклоняются CommunicationPolicy на отправке (INV-BL-3).
Блокировка гейтит новое общение, а не уже идущее: активный звонок (Ringing/Answered,
call-session.md) блокировкой не завершается — гейт CanCall проверяется в StartCall, но
повторный звонок паре уже запрещён; активная переписка Direct живёт, новые сообщения отклоняются (INV-BL-3).
Гонка «блок против отправки»: сообщение, начатое до фиксации блокировки, может пройти по ещё-действующей
политике — CommunicationPolicy вычисляется на момент отправки, не распределённой транзакцией.
Интеграционные Kafka-события (pin.messenger.*.v1) чёрным списком не публикуются: блокировка приватна,
кросс-сервисных потребителей нет, эффект локален для messenger. При появлении отдельного сервиса звонков он
читает CommunicationPolicy синхронно, а не по событию.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK btree (id); ux_blacklist_owner_blocked unique on (owner_user_id,blocked_user_id) — прямая проверка и «кого я заблокировал»; ix_blacklist_blocked btree (blocked_user_id) — обратная проверка политики «кто меня заблокировал»; ix_blacklist_created_at btree desc — список UI |
| Партиционирование / retention | none (объём мал; хранение бессрочно, пока блок активен; разблокировка — hard delete, ADR-0056) |
| Concurrency | Гонку одновременных BlockUser закрывает ux_blacklist_owner_blocked (второй вставке — конфликт → идемпотентная обработка); оптимистическая блокировка не нужна |
| Permissions / PII | messenger.blacklist.manage — блокировать/разблокировать и видеть собственный список. Record-level: пользователь видит и меняет только записи с OwnerUserId = actor. PII: OwnerUserId/BlockedUserId — id; Reason — приватная заметка инициатора (в логи не пишется). |
| Приватность | Факт блокировки виден только Owner. Заблокированному не раскрывается: его сообщения паре тихо отклоняются на отправке общей ошибкой MSG_COMMUNICATION_BLOCKED (без указания, кто и когда заблокировал) |
| Audit | отметка автора (CreatedByUserId); разблокировка (hard delete) фиксируется в аудите операции UnblockUser, а не soft-delete-полями |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: CallSessionEntity — сигналинг и лог звонка (media отложен), Entity / Aggregate: ChatEntity (+ParticipantEntity как owned-child), Entity / Aggregate: ContactEntity
- API (9): DELETE /api/messenger/blacklist/{blockedUserId} — Разблокировать пользователя, GET /api/messenger/blacklist — Мой чёрный список, GET /api/messenger/contacts/search — Глобальный поиск пользователя по телефону, POST /api/messenger/blacklist — Заблокировать пользователя, POST /api/messenger/calls — Начать звонок, POST /api/messenger/chats/{chatId}/messages/attachment — Отправить вложение, POST /api/messenger/chats/{chatId}/messages/text — Отправить текстовое сообщение, POST /api/messenger/chats/direct — Открыть/создать личный 1:1 чат, POST /api/messenger/contacts/requests — Отправить заявку в контакты
- Use Cases (3): UC-MSG-001. Открыть личный чат по поиску пользователя по телефону, UC-MSG-004. Заблокировать пользователя (чёрный список), UC-MSG-005. Сигналинг аудио/видео-звонка
- События (1): Event Contract: pin.messenger.contact-request-created (ContactRequestCreatedEvent)
- Фоновые задачи (2): Background Job: CallTimeoutReaperJob, Background Job: RealtimeDispatchJob
- registries (1): Реестр сущностей PIN
Связанные документы
- ADR-0055 — собственный мини-мессенджер, SignalR.
contact.md(заявка→подтверждение,Status = Accepted),communication-policy.md(доменный сервис политики общения) — соседние документы контекстаmessenger; README контекста.- identity/domain/user.md (логические ссылки
Owner/BlockedUserId), identity/domain/permission.md.