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

Entity: AuthorSubscription (лента subscription)

Дом полей подписки пользователя на автора/компанию (контекст news). Проекции — модель ответа подписок, use-case ленты — ссылаются сюда и перечисляют только дельту.

Назначение

Личная подписка пользователя-подписчика на источник новостей — автора-пользователя (агент, инвестор-блогер) или компанию (застройщик, агентство). Совокупность активных подписок одного пользователя задаёт его ленту subscription — один из двух режимов ленты новостей (второй, global, подписок не требует; бриф лент global/subscription).

Подписка — read-side состояние: она не порождает контент, а служит фильтром при сборке ленты. Лента строится запросом (pull), не пушится подписчику:

Лента subscription (list-subscription-feed, keyset по (PublishedAt, Id) DESC):
targets := AuthorSubscription where SubscriberUserId = @me and DeletedAt is null
authorUserIds := targets[TargetKind = Author ].TargetUserId
authorCompanyIds := targets[TargetKind = Company].TargetCompanyId
SELECT post
WHERE post.Status = 'Published'
AND (post.AuthorUserId = ANY(authorUserIds) OR post.AuthorCompanyId = ANY(authorCompanyIds))
ORDER BY post.PublishedAt DESC, post.Id DESC -- keyset < cursor

Дом полей поста и статус Publishedpost.md.

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

ПолеЗначение
Контекстnews
KindEntity (самостоятельный корень своей мини-транзакции; не AggregateRoot — событий не поднимает)
Source of truthэтот контекст (news)
Таблицаnews.author_subscriptions
Tenant isolationвне tenant — личная подписка пользователя-подписчика (инвестора/агента), не данные компании; изоляция — по SubscriberUserId из JWT, не по TenantId
Статуспроизводный от DeletedAt (Active / Deleted) — отдельной status-колонки нет
Concurrencyне требуется: toggle идемпотентен через partial-unique ux_subscriptions_target + upsert
АудитCreated/Updated/DeletedByUserId; актор всегда = SubscriberUserId (INV-SUB-004)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор подписки.
SubscriberUserIdGuidДаindex ix_subscriptions_subscriber where deleted_at is null; логическая ссылка на identity.users (без FK)JWTКто подписан. Берётся из контекста актора, не из тела запроса (INV-SUB-004).
TargetKindSubscriptionTargetKindДаenum (хранится как text), varchar(16); часть uniqueappAuthor / Company — тип источника.
TargetUserIdGuid?Условно (при TargetKind=Author)index ix_subscriptions_target_user where deleted_at is null; логическая ссылка на identity.users / AuthorReplica; XOR (INV-SUB-002)appАвтор-пользователь (агент, инвестор-блогер).
TargetCompanyIdGuid?Условно (при TargetKind=Company)index ix_subscriptions_target_company where deleted_at is null; логическая ссылка на identity.companies / CompanyReplica; XOR (INV-SUB-002)appКомпания (застройщик/агентство).
CreatedAtDateTimeOffsetДаappДата первой подписки; при reactivate сохраняется.
UpdatedAtDateTimeOffsetДаappПоследняя мутация (подписка/отписка/reactivate).
CreatedByUserId / UpdatedByUserId / DeletedByUserIdGuid?auditбез FK; = SubscriberUserIdappActor-stamping; для этой сущности всегда сам подписчик.
DeletedAtDateTimeOffset?Нетpartial-фильтр всех индексовappSoft-отписка: null = активна, заполнено = отписан.

Enum SubscriptionTargetKind

ЗначениеОписание
AuthorПодписка на автора-пользователя: заполнен TargetUserId, TargetCompanyIdnull.
CompanyПодписка на компанию: заполнен TargetCompanyId, TargetUserIdnull.

Value objects

Value objects не используются.

Модели JSONB

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

Связи

СвязьТипКлючПравило загрузкиDelete behavior
AuthorReplicareference (логическая)target_user_id при TargetKind=Authorподпись автора (имя/аватар) при рендере списка подписок — Filter репликреплика живёт своей жизнью; блокировка/удаление автора → пост-фильтр в ленте (запись подписки не трогается)
CompanyReplicareference (логическая)target_company_id при TargetKind=Companyподпись компании (название/логотип) — Filter репликто же
identity.users (подписчик)reference (логическая)subscriber_user_id«я» из JWT, реплика не нужна
Post (лента subscription)derived readAuthorUserId / AuthorCompanyId ∈ активные подпискиFilter постов по формуле из «Назначение»

Дом подписей автора/компании — identity-replicas.md.

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-SUB-001 UniqueTargetНе более одной живой подписки на цель: unique (subscriber_user_id, target_kind, coalesce(target_user_id, target_company_id)) where deleted_at is nullpartial-unique index ux_subscriptions_target + FindBy перед вставкойNEWS_SUBSCRIPTION_ALREADY_EXISTS
INV-SUB-002 TargetXorРовно одно из TargetUserId / TargetCompanyId заполнено, согласно TargetKind (Author→user, Company→company)check-constraint + validator операцииNEWS_SUBSCRIPTION_TARGET_INVALID
INV-SUB-003 NoSelfНельзя подписаться на себя: при TargetKind=AuthorSubscriberUserId != TargetUserIdvalidator операцииNEWS_SUBSCRIPTION_SELF
INV-SUB-004 RecordLevelУправляет строкой только сам подписчик: SubscriberUserId берётся из JWT, чтение/мутация чужих подписок невозможныactor-контекст операции (не из тела запроса)NEWS_SUBSCRIPTION_FORBIDDEN

Статус — производный от DeletedAt: Active (deleted_at is null) / Deleted. Повторная подписка не создаёт новую строку — reactivate существующей soft-удалённой (upsert по естественному ключу), DeletedAt → null, UpdatedAt обновляется, CreatedAt (дата первой подписки) сохраняется.

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

Событий нет. Подписка — личное read-side состояние подписчика; лента строится запросом (pull), поэтому ни доменных, ни интеграционных событий (pin.news.*.v1) не публикуется — downstream-подписчиков на «факт подписки» нет.

Аналитический сигнал клика подписки/отписки отправляет фронтенд напрямую в user-activity как ActionEvent (ADR-0054), а не доменным событием news: так весь клиентский поведенческий трафик остаётся в одном доме аналитики и не нагружает write-путь news.

Конкурентность и согласованность

  • Toggle идемпотентен. Естественный ключ (SubscriberUserId, TargetKind, coalesce(TargetUserId, TargetCompanyId)) под partial-unique ux_subscriptions_target (where deleted_at is null) гарантирует ≤ 1 живой подписки на цель. Повторный subscribe при живой подписке → NEWS_SUBSCRIPTION_ALREADY_EXISTS; повторный unsubscribe при уже отписанной → no-op (тот же результат).
  • Гонка двух подписок. Два одновременных subscribe на одну цель: partial-unique пропускает один insert, конкурент упирается в индекс и разрешается как reactivate/no-op той же строки — в итоге ровно одна активная подписка, без дублей.
  • Resubscribe сохраняет историю. Повторная подписка на ранее отписанную цель не создаёт новую строку — reactivate существующей (DeletedAt → null, UpdatedAt = now), CreatedAt (дата первой подписки) сохраняется для аналитики оттока.
  • Свежесть ленты. Лента subscription строится pull-запросом по активным подписке в момент запроса: подписка/отписка отражается в следующей выборке ленты сразу (без событий и кэш-инвалидации). Подпись автора/компании в списке подписок — из реплик (AuthorReplica/CompanyReplica), их свежесть eventual (лаг консюмера identity).

Acceptance Criteria

  • Подписка. Дано у пользователя нет подписки на автора A; когда он подписывается (TargetKind=Author, TargetUserId=A); тогда создаётся активная строка (DeletedAt = null), и посты A появляются в ленте subscription подписчика.
  • Повторная подписка (дубль). Дано активная подписка на A; когда subscribe повторяется; тогда NEWS_SUBSCRIPTION_ALREADY_EXISTS (INV-SUB-001), новая строка не создаётся.
  • Отписка и повторная подписка. Дано активная подписка; когда unsubscribe (soft, DeletedAt = now), затем снова subscribe; тогда reactivate прежней строки, CreatedAt сохранён, DeletedAt = null.
  • XOR цели. Дано TargetKind=Author без TargetUserId (или заполнены обе цели); когда операция; тогда NEWS_SUBSCRIPTION_TARGET_INVALID (INV-SUB-002).
  • Подписка на себя. Дано TargetKind=Author, TargetUserId == SubscriberUserId; когда subscribe; тогда NEWS_SUBSCRIPTION_SELF (INV-SUB-003).
  • Record-level. Дано попытка управлять/читать чужие подписки (иной SubscriberUserId); когда операция; тогда NEWS_SUBSCRIPTION_FORBIDDENSubscriberUserId всегда из JWT, не из тела (INV-SUB-004).
  • Идемпотентная отписка. Дано подписки нет или уже отписан; когда unsubscribe; тогда no-op (результат — «не подписан»), без ошибки.

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

АспектЗначение
ИндексыPK pk_author_subscriptions (id); ux_subscriptions_target unique btree по выражению (subscriber_user_id, target_kind, coalesce(target_user_id, target_company_id)) where deleted_at is null (реализует INV-SUB-001; coalesce-форма вместо 4 колонок — иначе NULLS DISTINCT не ловит дубли по nullable-цели); ix_subscriptions_subscriber (subscriber_user_id) where deleted_at is null — «мои подписки» и сбор целей ленты; ix_subscriptions_target_user (target_user_id) where deleted_at is null; ix_subscriptions_target_company (target_company_id) where deleted_at is null — счётчики подписчиков автора/компании
Партиционирование / retentionnone; история отписок сохраняется soft-delete-ом (аналитика оттока), физического удаления нет
Concurrencyне требуется: toggle идемпотентен через ux_subscriptions_target + upsert (повторный subscribe = reactivate, повторный unsubscribe — no-op)
Permissions / PIIPublic API (../api/public/subscriptions/*): только владелец-подписчик по JWT; SubscriberUserId — из контекста актора, не из тела (INV-SUB-004); tenant-интерсепторы не применяются (вне tenant). PII нет — только id-ссылки
AuditActor-stamping (*ByUserId = подписчик) + soft-delete; переходы Active↔Deleted видны по DeletedAt/UpdatedAt

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

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

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