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

Entity / Aggregate: BlacklistEntity

Дом полей агрегата «Запись чёрного списка» контекста messenger (контакты, звонки, чёрный список; ADR-0055). Все проекции (список заблокированных в UI, вычисление CommunicationPolicy, отклонение отправки сообщения/звонка) ссылаются сюда и описывают только дельту, а не переописывают поля.

Назначение

BlacklistEntity — направленная запись блокировки «Owner заблокировал Blocked»: один пользователь запрещает другому писать и звонить себе. Модель — простой агрегат-факт: строка либо существует (блок активен), либо нет (разблокировано, строка удалена). Отдельного Status-поля нет.

Эффект для доменного сервиса CommunicationPolicyвзаимный запрет: если любая из сторон заблокировала другую, общение недоступно обеим (ни писать, ни звонить), пока хотя бы одна запись существует. Сам факт блокировки виден только инициатору (Owner); заблокированному он не раскрывается — его сообщения тихо отклоняются на отправке с общей ошибкой (см. Безопасность).

Классификация

ПолеЗначение
Контекстmessenger
KindAggregateRoot (простой агрегат-факт)
Source of truthmessenger
Таблицаmessenger.blacklist_entries
Tenant isolationНет: блокировка — отношение пользователь↔пользователь, вне компаний/tenant. Изоляция запросов — по OwnerUserId = actor (record-level, см. Безопасность).

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор записи блокировки.
OwnerUserIdGuidДаindex ix_blacklist_owner (в составе ux_blacklist_owner_blocked); логическая ссылка на identity.users(id)app (actor)Кто заблокировал (владелец записи).
BlockedUserIdGuidДаindex ix_blacklist_blocked; часть ux_blacklist_owner_blocked; логическая ссылка на identity.users(id)appКого заблокировали.
Reasonstring?Нетvarchar(256)app (форма блокировки)Необязательная причина (личная заметка инициатора; заблокированному не показывается).
CreatedAtDateTimeOffsetДаindex ix_blacklist_created_at descappМомент блокировки.
CreatedByUserIdGuidДа= 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-3CanMessage(a,b) = ¬∃ blacklist(a→b) ∧ ¬∃ blacklist(b→a); CanCall(a,b) = CanMessage(a,b) ∧ ∃ Contact(a,b).Status = AcceptedCommunicationPolicy при отправке сообщения и при сигналинге звонка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
Партиционирование / retentionnone (объём мал; хранение бессрочно, пока блок активен; разблокировка — hard delete, ADR-0056)
ConcurrencyГонку одновременных BlockUser закрывает ux_blacklist_owner_blocked (второй вставке — конфликт → идемпотентная обработка); оптимистическая блокировка не нужна
Permissions / PIImessenger.blacklist.manage — блокировать/разблокировать и видеть собственный список. Record-level: пользователь видит и меняет только записи с OwnerUserId = actor. PII: OwnerUserId/BlockedUserId — id; Reason — приватная заметка инициатора (в логи не пишется).
ПриватностьФакт блокировки виден только Owner. Заблокированному не раскрывается: его сообщения паре тихо отклоняются на отправке общей ошибкой MSG_COMMUNICATION_BLOCKED (без указания, кто и когда заблокировал)
Auditотметка автора (CreatedByUserId); разблокировка (hard delete) фиксируется в аудите операции UnblockUser, а не soft-delete-полями

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

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

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

  • ADR-0055 — собственный мини-мессенджер, SignalR.
  • contact.md (заявка→подтверждение, Status = Accepted), communication-policy.md (доменный сервис политики общения) — соседние документы контекста messenger; README контекста.
  • identity/domain/user.md (логические ссылки Owner/BlockedUserId), identity/domain/permission.md.