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
| Поле | Значение |
|---|---|
| ID | UC-NWS-004 |
| Контекст | news (см. docs/06-services/news/) |
| Тип | основной |
| Статус | draft |
Актор
| Роль | Permissions / scope | Tenant | Record-level |
|---|---|---|---|
| User (подписчик) | аутентификация identity; отдельного permission нет — self-scope; feature flag news.subscriptions.enabled | null — личная подписка вне tenant (ADR-0052 п.4); изоляция по SubscriberUserId | владелец = SubscriberUserId; чтение/мутация чужих подписок невозможны (INV-SUB-004) |
System (потребители identity) | system-scope | не применяется | наполняют AuthorReplica/CompanyReplica (подписи, статусы) |
Guest (без аутентификации): доступна только лента global и чтение опубликованного поста; подписки и лента
subscription — 401.
Предусловие
- Пользователь аутентифицирован;
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} |
Основной Поток
Нить — подписка → чтение ленты → отписка.
- Подписка (вход). Пользователь жмёт «Подписаться» на карточке автора/компании. Тело:
TargetKind(SubscriptionTargetKindстрокойAuthor/Company) + XOR-цельTargetUserId?/TargetCompanyId?;SubscriberUserId— из контекста актора. Дом — subscribe.md. - Валидация подписки.
TargetKind ∈ {Author, Company}; XOR цели по kind (INV-SUB-002) — ровно одно изTargetUserId/TargetCompanyId,!= Guid.Empty; не-self (INV-SUB-003) — приAuthorSubscriberUserId != TargetUserId. Нарушение →NEWS_SUBSCRIPTION_TARGET_INVALID/NEWS_SUBSCRIPTION_SELF(400). - Upsert подписки (reactivate). По естественному ключу цели
target := coalesce(TargetUserId, TargetCompanyId)найти существующую подписку (partial-uniqueux_subscriptions_target), включая soft-deleted. Ветки (INV-SUB-001): нет строки → создатьAuthorSubscription([*] → Active); строка soft-deleted → reactivate (DeletedAt = null,UpdatedAt = now,CreatedAtпервой подписки сохранён;Deleted → Active); строка активна → идемпотентный no-op (A3).AuthorSubscriptionдоменных событий не поднимает. - Коммит подписки. Одна строка
news.author_subscriptionsфиксируется атомарно (интеграционных событий нет). Ответ{ SubscriptionId, TargetKind, Target }→201(создана) /200(reactivate или уже активна). Клик подписки фронт шлёт отдельно вuser-activityкакActionEvent(ADR-0054), не write-путёмnews. - Чтение ленты
subscription(вход).GET /api/v1/feed/subscriptions?cursor&limit&categoryId(SubscriberUserIdиз контекста;Limit ∈ 1..30, default 20). Дом — list-subscription-feed.md. - Сбор целей. Загрузить активные подписки актора (
DeletedAt == null), разбить наauthorUserIds(TargetKind = Author) иauthorCompanyIds(TargetKind = Company). Оба множества пусты → A1 (IsEmptySubscriptions = true, пустая страница). - Keyset-выборка постов. Отобрать посты:
Post.Status == Published∧ (AuthorUserId ∈ authorUserIds∨AuthorCompanyId ∈ authorCompanyIds) ∧ опц.CategoryId∧ исключение suspended-компаний (INV-REP-003, A2) ∧ keyset(PublishedAt, Id) < cursor; порядокPublishedAt desc, Id desc. Автор в статусеBlockedостаётся в выдаче, подпись помечается на рендере (INV-REP-002). - Подписи из реплик + результат. Подписи авторов/компаний берутся батчем из реплик по
AuthorUserId/AuthorCompanyIdстраницы (без N+1); маппинг вPostFeedItem;NextCursor— base64url(PublishedAt, Id)последнего элемента приHasMore. Ответ{ Page: PostFeedItem[], IsEmptySubscriptions }→200. - Отписка.
DELETE /api/v1/subscriptions/{id}. Найти подписку поId(вкл. soft-deleted): не найдена →NEWS_SUBSCRIPTION_NOT_FOUND(404); чужойSubscriberUserId→NEWS_SUBSCRIPTION_FORBIDDEN(403, INV-SUB-004); уже soft-deleted → идемпотентный no-op. Иначе переводActive → Deleted(DeletedAt = now, actor-stamping) — soft-delete, история оттока сохраняется →204. Дом — unsubscribe.md.
Диаграмма
Альтернативные Потоки
A1. Пустые подписки → пустая лента
- Шаг 6: у актора нет активных подписок (
authorUserIdsиauthorCompanyIdsпусты). - Keyset-выборка (шаг 7) пропускается; страница пуста (
Items = [],NextCursor = null,HasMore = false),IsEmptySubscriptions = true, HTTP200. - Фронт по флагу показывает CTA «подпишитесь на авторов/компании» вместо пустого списка.
Завершается успешно (не ошибка).
A2. Подписка на suspended-компанию → её посты не показываются (INV-REP-003)
- Подписка на компанию создаётся штатно (шаги 1–4) — статус источника при подписке не проверяется.
- Потребитель
pin.identity.companies.v1(CompanySuspended/CompanyUpserted[Suspended]) переводитCompanyReplica.Status = Suspended. - Шаг 7: фильтр выдачи исключает посты
Post.AuthorCompanyIdsuspended-компании из ленты (INV-REP-003); контент и запись подписки не удаляются. Снятие suspend вidentity→ посты возвращаются. - Окно eventual-consistency: между
CompanySuspendedвidentityи обновлениемCompanyReplicaвnewsпроходит лаг потребления (pin.identity.companies.v1, секунды–минуты). В этом окне подписчик может кратко видеть посты только что заблокированной компании; после применения реплики они исчезают из ленты. Симметрично при снятии suspend — посты возвращаются с тем же лагом. Синхронно статус источника лента не проверяет (ADR-0056).
Возвращается к основному потоку на шаг 8 (без постов исключённой компании).
A3. Дубль подписки → идемпотентно (INV-SUB-001)
- Шаг 3: найдена активная строка на ту же цель (
DeletedAt == null). - Новая строка не создаётся, состояние не меняется — идемпотентный no-op;
200(SubscriptionIdстабилен). Гонка параллельной вставки → нарушение uniqueux_subscriptions_target→ перечитать подписку и вернуть тот же200.
Завершается идемпотентно.
Постусловие
- Лента
subscriptionотражает актуальные активные подписки: постыPublishedподписанных авторов/компаний, keysetPublishedAt 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_INVALID | 400 | нарушен XOR цели/kind или неизвестный TargetKind (INV-SUB-002) | «Некорректная цель подписки» |
NEWS_SUBSCRIPTION_SELF | 400 | Author и SubscriberUserId == TargetUserId (INV-SUB-003) | «Нельзя подписаться на себя» |
VALIDATION_ERROR | 400 | лента: Limit вне 1..30, CategoryId == Guid.Empty, битый Cursor; отписка: Id == Guid.Empty | «Некорректные параметры запроса» |
NEWS_SUBSCRIPTION_NOT_FOUND | 404 | отписка: подписки с Id не существует | «Подписка не найдена» |
NEWS_SUBSCRIPTION_FORBIDDEN | 403 | отписка чужой подписки (SubscriberUserId != actor, INV-SUB-004) | «Недостаточно прав» |
UNAUTHORIZED | 401 | лента/подписка/отписка без аутентификации (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
| # | Критерий | Проверка |
|---|---|---|
| AC1 | XOR цели и не-self валидны: Company без TargetCompanyId, Author с обоими id, подписка на себя → 400 (TARGET_INVALID/SELF) | Unit-тест валидации подписки (INV-SUB-002/003) |
| AC2 | Валидная подписка создаёт AuthorSubscription(Active); лента содержит Published-посты этой цели, keyset PublishedAt desc | Integration: подписка → publish поста цели → пост в /feed/subscriptions |
| AC3 | Suspended-компания исключена: после CompanySuspended посты компании не в ленте, запись подписки цела; снятие suspend возвращает посты | Integration A2: CompanyReplica.Suspended → пост отсутствует (INV-REP-003) |
| AC4 | Reactivate: отписка (204) + повторная подписка → та же строка (SubscriptionId стабилен), DeletedAt = null, CreatedAt сохранён, 200 | Integration: subscribe→unsubscribe→subscribe, одна строка |
| AC5 | Дубль подписки идемпотентен: повтор на активную цель → 200, одна строка (INV-SUB-001) | Integration A3: два POST подряд, count = 1 |
| AC6 | Пустые подписки → 200, IsEmptySubscriptions = true, Items = [] | Integration A1: пользователь без подписок |
| AC7 | Guest без аутентификации → 401 на ленту/подписку/отписку; чужая подписка → 403 (INV-SUB-004) | Integration: без токена + чужой Id |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity: AuthorSubscription (лента subscription)
- API (2): DELETE /api/v1/subscriptions/{id} — Отписаться от источника, GET /api/v1/feed/subscriptions — Лента по подпискам
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Операции: subscribe.md,
unsubscribe.md,
list-subscriptions.md; соседний режим — глобальная
лента list-global-feed.md (дом
PostFeedItem). - Domain: author-subscription.md (поля, INV-SUB-001..004, переходы
Active ↔ Deleted,ux_subscriptions_target), post.md (статусPublished,AuthorUserId/AuthorCompanyId,PostFeedItem), identity-replicas.md (подписи, INV-REP-002/003). - Kafka: post-published.md (пост входит в ленту — целевой путь по
конвенции); потребители
identityнаполняют реплики. - README контекста: news/README.md.
- ADR: 0052 tenancy (вне tenant),
0054 user-activity (клик →
user-activity), 0056 simplicity-first (без синхронной проверки цели, PostgreSQL-keyset).