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

Entity / Aggregate: ContactEntity

Дом полей агрегата «контакт-связь» контекста messenger. Все проекции (списки контактов и входящих заявок, маскированная/полная карточка профиля, payload pin.messenger.contact-request-created.v1 и pin.messenger.contact-request-accepted.v1) ссылаются сюда и описывают только дельту, а не переописывают поля.

Назначение

ContactEntity — направленная заявка на контакт и её жизненный цикл до двусторонней связи. Инициатор (Requester) отправляет заявку адресату (Addressee); при подтверждении связь становится двусторонней: обе стороны видят полный профиль друг друга (ФИО + телефон, дом — messenger-user.md) и получают право звонка (call-session.md, INV-CALL-1). До подтверждения профиль маскирован (фамилия — первой буквой), звонок недоступен.

Aggregate boundary — одна запись связи для неупорядоченной пары пользователей; source of truth — messenger. Справочник пользователей — replica-контекст messenger-user; чёрные списки — blacklist.md.

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

ПолеЗначение
Контекстmessenger
KindAggregateRoot
Source of truthmessenger
Таблицаmessenger.contacts
Tenant isolationнет — граф контактов person-level; изоляция record-level по участникам пары (RequesterUserId / AddresseeUserId)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор связи.
RequesterUserIdGuidДаindex ix_contacts_requester (RequesterUserId,Status); FK-логическая → messenger-userapp (актор)Инициатор заявки.
AddresseeUserIdGuidДаindex ix_contacts_addressee (AddresseeUserId,Status); FK-логическая → messenger-userapp (форма заявки)Адресат заявки.
PairKeystringДаvarchar(128); unique partial ux_contacts_active_pair (PairKey) where Status in ('Pending','Accepted')app (детерминированно)Ключ неупорядоченной пары {min}:{max} из двух Guid (N-формат): Requester/Addressee в порядке возрастания — заявки A→B и B→A дают один PairKey (INV-CT-2).
StatusContactStatusДа.HasConversion<string>(), varchar(16); ведущий столбец composite-индексов requester/addresseeappСм. enum и переходы.
RequestMessagestring?Нетvarchar(512)app (форма заявки)Сопроводительный текст заявки; null — без сообщения.
RespondedAtDateTimeOffset?НетappМомент ответа (Accept/Decline/Cancel); null — ещё Pending.
RespondedByUserIdGuid?НетappКто закрыл заявку: адресат (Accept/Decline) или сторона-инициатор ремува/отзыва.
CreatedAtDateTimeOffsetДаindex (через composite)appМомент подачи заявки.
CreatedByUserIdGuid?НетappКто создал заявку; совпадает с RequesterUserId.
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-remove контакта; выводит строку из ux_contacts_active_pair.
DeletedByUserIdGuid?НетappКто удалил контакт.

Enum ContactStatus

ЗначениеОписание
PendingЗаявка отправлена, ждёт ответа адресата.
AcceptedПодтверждён — двусторонний контакт (полный профиль + право звонка).
DeclinedОтклонён адресатом.
CancelledОтозван инициатором (до ответа) или удалён любой стороной (после Accepted).

Value objects

Value objects не используются. PairKey — вычисляемая скалярная строка (детерминированная функция пары), хранится материализованно ради partial-unique-индекса. ФИО (FullName) и телефон — дом messenger-user.md, в этот агрегат не проецируются.

Модели JSONB (versioned)

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
MessengerUserEntity (инициатор)replica-referenceRequesterUserIdпроверка существования при создании (INV-CT-6)restrict
MessengerUserEntity (адресат)replica-referenceAddresseeUserIdпроверка существования при создании (INV-CT-6)restrict
BlacklistEntityreference (guard)пара Requester/Addresseeпроверка при создании (INV-CT-4)
CallSessionEntityобратная (право звонка)параактивна только при Status = Accepted (INV-CT-5)

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-CT-1Нельзя заявка на себя: RequesterUserId != AddresseeUserIdпри создании заявки + check ck_contacts_not_selfMSG_CONTACT_SELF
INV-CT-2Не более одной активной связи на пару. Повторная заявка при существующем Pending/Accepted → конфликт (или возврат существующей)при создании: unique partial ux_contacts_active_pair + проверка активной связи по PairKeyMSG_CONTACT_EXISTS
INV-CT-3Pending→Accepted/Declined — только AddresseeUserId; Pending/Accepted→CancelledRequesterUserId (отзыв) либо любая сторона (удаление контакта)переходы Accept / Decline / Cancel (проверка актора)MSG_CONTACT_FORBIDDEN / MSG_CONTACT_INVALID_STATE
INV-CT-4Нельзя создать заявку, если пара в чёрном списке любой стороныguard по blacklist.md при создании заявкиMSG_CONTACT_BLOCKED
INV-CT-5Accepted ⇒ обе стороны получают полный профиль (ФИО + телефон, messenger-user.md) и право звонка (call-session.md INV-CALL-1); до Accepted профиль маскирован (фамилия — первой буквой)проекция карточки профиля + guard создания звонкаMSG_CONTACT_NOT_ACCEPTED
INV-CT-6Обе стороны — существующие пользователипроверка существования обоих пользователей при созданииMSG_CONTACT_USER_NOT_FOUND

Конкурентность и eventual-consistency. Встречные заявки A→B и B→A, поданные одновременно, дают один PairKey: партиальный уникальный ux_contacts_active_pair пропускает одну (первую) как Pending, вторая упирается в конфликт MSG_CONTACT_EXISTS (или ей возвращается уже созданная связь). Одновременные Accept и Cancel разрешает оптимистическая блокировка: проигравший перечитывает состояние и следует переходам INV-CT-3. Проверка существования сторон (INV-CT-6) опирается на реплику messenger-user, которая eventual: только что зарегистрированный адресат может ещё отсутствовать в реплике → MSG_CONTACT_USER_NOT_FOUND (допустимая гонка, сходится после user-upserted). Блокировка (INV-CT-4), добавленная между подачей и подтверждением, не откатывает Accepted-связь, но CommunicationPolicy немедленно запрещает общение по паре (blacklist.md).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
ContactRequestCreatedдоменноеСоздан PendingId, RequesterUserId, AddresseeUserId
ContactRequestAcceptedдоменноеPending → AcceptedId, RequesterUserId, AddresseeUserId, RespondedByUserId
ContactDeclined / ContactCancelledдоменное→ Declined / → CancelledId, RespondedByUserId — push в SignalR-hub (ContactRequestDeclined/ContactRemoved), без Kafka
ContactRequestCreatedEventинтеграционное, topic pin.messenger.contact-request-created.v1, ContractVersion = 1Создан PendingContactId, RequesterUserId, AddresseeUserId, RequestMessage?, CreatedAt; консюмер: notifications (уведомляет адресата) + push ContactRequestReceived в hub; контракт — ../events/contact-request-created.md
ContactRequestAcceptedEventинтеграционное, topic pin.messenger.contact-request-accepted.v1, ContractVersion = 1Pending → AcceptedContactId, RequesterUserId, AddresseeUserId, RespondedAt, RespondedByUserId; консюмеры: notifications, разблокировка звонков (call-session.md) + push обеим сторонам; контракт — ../events/contact-request-accepted.md

Доменные события поднимаются при переходах статуса; интеграционные события публикуются через outbox атомарно с переходом статуса (в одном согласованном изменении).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; ux_contacts_active_pair unique partial (PairKey) where Status in ('Pending','Accepted'); ix_contacts_requester (RequesterUserId,Status); ix_contacts_addressee (AddresseeUserId,Status)
Партиционирование / retentionnone (граф контактов, история хранится бессрочно)
Concurrencyоптимистическая блокировка: одновременные Accept / Cancel — проигравший получает конфликт и перечитывает состояние
Permissions / PIImessenger.contacts.manage. Видимость строки — только участникам пары (RequesterUserId / AddresseeUserId). PII: связь раскрывает полный профиль (ФИО + телефон) обеим сторонам только при Status = Accepted (INV-CT-5); до Accepted — маскированная проекция; в логи — только Id/PairKey-hash
Auditотметки создания/изменения/удаления (кто и когда); факт ответа (RespondedAt, RespondedByUserId) иммутабелен

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

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

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

  • messenger-user.md (справочник пользователей, ФИО/телефон, маскирование), blacklist.md (guard INV-CT-4), call-session.md (право звонка INV-CT-5).
  • Контракты событий: ../events/contact-request-created.md, ../events/contact-request-accepted.md.