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

UC-NWS-004. Подписки и лента subscription

Назначение

Пользователь-подписчик подписывается на автора или компанию, читает персональную ленту subscription (посты Published подписанных источников) и отписывается. Лента subscription — один из двух режимов ленты новостей (второй, global, подписок не требует). Подписка — read-side состояние (фильтр сборки, не push-канал); лента строится pull-запросом. Дом полей и инвариантов — author-subscription.md, post.md, identity-replicas.md; операции — subscribe.md, unsubscribe.md, list-subscription-feed.md.

Metadata

ПолеЗначение
IDUC-NWS-004
Контекстnews (см. docs/06-services/news/)
Типосновной
Статусdraft

Актор

РольPermissions / scopeTenantRecord-level
User (подписчик)аутентификация identity; отдельного permission нет — self-scope; feature flag news.subscriptions.enablednull — личная подписка вне tenant (ADR-0052 п.4); изоляция по SubscriberUserIdвладелец = SubscriberUserId; чтение/мутация чужих подписок невозможны (INV-SUB-004)
System (потребители identity)system-scopeне применяетсянаполняют AuthorReplica/CompanyReplica (подписи, статусы)

Guest (без аутентификации): доступна только лента global и чтение опубликованного поста; подписки и лента subscription401.

Предусловие

  • Пользователь аутентифицирован; SubscriberUserId берётся из контекста актора, не из тела/query (INV-SUB-004).
  • Цель подписки существует в identity (автор-пользователь или компания); синхронно не проверяется (ADR-0056) — битая ссылка отфильтруется при сборке ленты через реплики.
  • Реплики подписей (AuthorReplica/CompanyReplica) наполняются потребителями identity (свежесть — eventual); отсутствие реплики не блокирует подписку.

Триггер

ПолеЗначение
ИсточникUI button (кнопка «Подписаться»/«Отписаться», экран ленты subscription) → API call
Триггерящий элементPOST /api/v1/subscriptions, GET /api/v1/feed/subscriptions, DELETE /api/v1/subscriptions/{id}

Основной Поток

Нить — подписка → чтение ленты → отписка.

  1. Подписка (вход). Пользователь жмёт «Подписаться» на карточке автора/компании. Тело: TargetKind (SubscriptionTargetKind строкой Author/Company) + XOR-цель TargetUserId?/TargetCompanyId?; SubscriberUserId — из контекста актора. Дом — subscribe.md.
  2. Валидация подписки. TargetKind ∈ {Author, Company}; XOR цели по kind (INV-SUB-002) — ровно одно из TargetUserId/TargetCompanyId, != Guid.Empty; не-self (INV-SUB-003) — при Author SubscriberUserId != TargetUserId. Нарушение → NEWS_SUBSCRIPTION_TARGET_INVALID / NEWS_SUBSCRIPTION_SELF (400).
  3. Upsert подписки (reactivate). По естественному ключу цели target := coalesce(TargetUserId, TargetCompanyId) найти существующую подписку (partial-unique ux_subscriptions_target), включая soft-deleted. Ветки (INV-SUB-001): нет строки → создать AuthorSubscription ([*] → Active); строка soft-deleted → reactivate (DeletedAt = null, UpdatedAt = now, CreatedAt первой подписки сохранён; Deleted → Active); строка активна → идемпотентный no-op (A3). AuthorSubscription доменных событий не поднимает.
  4. Коммит подписки. Одна строка news.author_subscriptions фиксируется атомарно (интеграционных событий нет). Ответ { SubscriptionId, TargetKind, Target }201 (создана) / 200 (reactivate или уже активна). Клик подписки фронт шлёт отдельно в user-activity как ActionEvent (ADR-0054), не write-путём news.
  5. Чтение ленты subscription (вход). GET /api/v1/feed/subscriptions?cursor&limit&categoryId (SubscriberUserId из контекста; Limit ∈ 1..30, default 20). Дом — list-subscription-feed.md.
  6. Сбор целей. Загрузить активные подписки актора (DeletedAt == null), разбить на authorUserIds (TargetKind = Author) и authorCompanyIds (TargetKind = Company). Оба множества пусты → A1 (IsEmptySubscriptions = true, пустая страница).
  7. Keyset-выборка постов. Отобрать посты: Post.Status == Published ∧ (AuthorUserId ∈ authorUserIdsAuthorCompanyId ∈ authorCompanyIds) ∧ опц. CategoryIdисключение suspended-компаний (INV-REP-003, A2) ∧ keyset (PublishedAt, Id) < cursor; порядок PublishedAt desc, Id desc. Автор в статусе Blocked остаётся в выдаче, подпись помечается на рендере (INV-REP-002).
  8. Подписи из реплик + результат. Подписи авторов/компаний берутся батчем из реплик по AuthorUserId/AuthorCompanyId страницы (без N+1); маппинг в PostFeedItem; NextCursor — base64url (PublishedAt, Id) последнего элемента при HasMore. Ответ { Page: PostFeedItem[], IsEmptySubscriptions }200.
  9. Отписка. DELETE /api/v1/subscriptions/{id}. Найти подписку по Id (вкл. soft-deleted): не найдена → NEWS_SUBSCRIPTION_NOT_FOUND (404); чужой SubscriberUserIdNEWS_SUBSCRIPTION_FORBIDDEN (403, INV-SUB-004); уже soft-deleted → идемпотентный no-op. Иначе перевод Active → Deleted (DeletedAt = now, actor-stamping) — soft-delete, история оттока сохраняется → 204. Дом — unsubscribe.md.

Диаграмма

Альтернативные Потоки

A1. Пустые подписки → пустая лента

  1. Шаг 6: у актора нет активных подписок (authorUserIds и authorCompanyIds пусты).
  2. Keyset-выборка (шаг 7) пропускается; страница пуста (Items = [], NextCursor = null, HasMore = false), IsEmptySubscriptions = true, HTTP 200.
  3. Фронт по флагу показывает CTA «подпишитесь на авторов/компании» вместо пустого списка.

Завершается успешно (не ошибка).

A2. Подписка на suspended-компанию → её посты не показываются (INV-REP-003)

  1. Подписка на компанию создаётся штатно (шаги 1–4) — статус источника при подписке не проверяется.
  2. Потребитель pin.identity.companies.v1 (CompanySuspended/CompanyUpserted[Suspended]) переводит CompanyReplica.Status = Suspended.
  3. Шаг 7: фильтр выдачи исключает посты Post.AuthorCompanyId suspended-компании из ленты (INV-REP-003); контент и запись подписки не удаляются. Снятие suspend в identity → посты возвращаются.
  4. Окно eventual-consistency: между CompanySuspended в identity и обновлением CompanyReplica в news проходит лаг потребления (pin.identity.companies.v1, секунды–минуты). В этом окне подписчик может кратко видеть посты только что заблокированной компании; после применения реплики они исчезают из ленты. Симметрично при снятии suspend — посты возвращаются с тем же лагом. Синхронно статус источника лента не проверяет (ADR-0056).

Возвращается к основному потоку на шаг 8 (без постов исключённой компании).

A3. Дубль подписки → идемпотентно (INV-SUB-001)

  1. Шаг 3: найдена активная строка на ту же цель (DeletedAt == null).
  2. Новая строка не создаётся, состояние не меняется — идемпотентный no-op; 200 (SubscriptionId стабилен). Гонка параллельной вставки → нарушение unique ux_subscriptions_target → перечитать подписку и вернуть тот же 200.

Завершается идемпотентно.

Постусловие

  • Лента subscription отражает актуальные активные подписки: посты Published подписанных авторов/компаний, keyset PublishedAt desc, Id desc, подписи из реплик; suspended-компании исключены (INV-REP-003), self и дубли невозможны.
  • Подписка: создана новая AuthorSubscription (Active) или reactivate soft-удалённой (CreatedAt первой подписки сохранён) — не более одной живой строки на цель (INV-SUB-001).
  • Отписка: строка переведена в Deleted (soft, DeletedAt задан) — цель исчезает из выборки ленты, история сохранена; повторная подписка reactivate-ит ту же строку.
  • Интеграционных событий news не порождается; write-путь news не нагружается кликами (аналитика — фронт → user-activity, ADR-0054).

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
NEWS_SUBSCRIPTION_TARGET_INVALID400нарушен XOR цели/kind или неизвестный TargetKind (INV-SUB-002)«Некорректная цель подписки»
NEWS_SUBSCRIPTION_SELF400Author и SubscriberUserId == TargetUserId (INV-SUB-003)«Нельзя подписаться на себя»
VALIDATION_ERROR400лента: Limit вне 1..30, CategoryId == Guid.Empty, битый Cursor; отписка: Id == Guid.Empty«Некорректные параметры запроса»
NEWS_SUBSCRIPTION_NOT_FOUND404отписка: подписки с Id не существует«Подписка не найдена»
NEWS_SUBSCRIPTION_FORBIDDEN403отписка чужой подписки (SubscriberUserId != actor, INV-SUB-004)«Недостаточно прав»
UNAUTHORIZED401лента/подписка/отписка без аутентификации (guest)«Требуется авторизация» (войти)
NEWS_SUBSCRIPTION_ALREADY_EXISTS— (internal)гонка вставки: unique ux_subscriptions_target (INV-SUB-001)handled: перечитать подписку → идемпотентный 200

Дубль подписки (A3), пустые подписки (A1), повтор отписки — не ошибки: идемпотентный 200/204.

Доменные События

СобытиеКогда поднимаетсяЭффект
— (нет)Подписка/отписка/лента — read-side состояние: AuthorSubscription доменных событий не поднимаетне применяется — author-subscription.md «Событий нет»

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
— (не публикуются)UC подписки/ленты news-событий не порождаетне применяется

Потребляемые upstream-события, формирующие ленту (публикуются вне этого UC): PostPublishedKafkaEvent (pin.news.post-published.v1, post.md → пост попадает в ленту при Publish); CompanySuspended/CompanyUpserted (pin.identity.companies.v1) и UserUpserted/UserBlocked (pin.identity.users.v1) — потребители обновляют CompanyReplica/AuthorReplica (A2 — INV-REP-003; INV-REP-002).

Acceptance Criteria

#КритерийПроверка
AC1XOR цели и не-self валидны: Company без TargetCompanyId, Author с обоими id, подписка на себя → 400 (TARGET_INVALID/SELF)Unit-тест валидации подписки (INV-SUB-002/003)
AC2Валидная подписка создаёт AuthorSubscription(Active); лента содержит Published-посты этой цели, keyset PublishedAt descIntegration: подписка → publish поста цели → пост в /feed/subscriptions
AC3Suspended-компания исключена: после CompanySuspended посты компании не в ленте, запись подписки цела; снятие suspend возвращает постыIntegration A2: CompanyReplica.Suspended → пост отсутствует (INV-REP-003)
AC4Reactivate: отписка (204) + повторная подписка → та же строка (SubscriptionId стабилен), DeletedAt = null, CreatedAt сохранён, 200Integration: subscribe→unsubscribe→subscribe, одна строка
AC5Дубль подписки идемпотентен: повтор на активную цель → 200, одна строка (INV-SUB-001)Integration A3: два POST подряд, count = 1
AC6Пустые подписки → 200, IsEmptySubscriptions = true, Items = []Integration A1: пользователь без подписок
AC7Guest без аутентификации → 401 на ленту/подписку/отписку; чужая подписка → 403 (INV-SUB-004)Integration: без токена + чужой Id

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

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

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