GET /api/v1/feed/subscriptions — Лента по подпискам
Назначение
Персональная лента новостей авторизованного пользователя: посты авторов и компаний, на которых он
подписан. Это один из двух режимов ленты (второй — глобальный, подписок не требует). Подписка здесь —
фильтр сборки, а не push-канал (author-subscription.md). Если
у пользователя нет активных подписок, лента возвращает пустую страницу с флагом IsEmptySubscriptions,
чтобы фронт показал приглашение подписаться. Только чтение — без мутаций и событий.
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | feed |
| Статус | draft |
| Owner | PIN Engagement |
| Документ | docs/06-services/news/api/public/feed/list-subscription-feed.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (JWT обязателен; гость → 401) |
| Auth policy / Permissions | аутентифицированный пользователь; отдельного permission нет (self-scope подписчика) |
| Scope (RBAC) | не применяется |
| Record-level | лента строится только по подпискам самого актора: SubscriberUserId — из JWT, не из query (INV-SUB-004) |
| Tenant isolation | не применяется — личная подписка вне tenant (ADR-0052 п.4); изоляция по SubscriberUserId |
| Audit | none (read); клик/просмотр — фронт → user-activity (ADR-0054) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/feed/subscriptions |
| Success status | 200 |
| Idempotency header | не применяется (GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
SubscriberUserId | identity claims | Guid | Да (вычисляется) | из JWT; отсутствует/невалиден → 401 | не из query (INV-SUB-004) |
Cursor | query | string? | Нет | opaque base64url ключа (PublishedAt, Id) последнего элемента; невалидный формат → 400; null — первая страница | — |
Limit | query | int | Нет (default 20) | 1..30 | — |
CategoryId | query | Guid? | Нет | != Guid.Empty при задании; существование не проверяется (несуществующий id → пустая выдача) | — |
Модель ответа SubscriptionFeedResult
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Page | страница ленты (PostFeedItem) | Да | keyset-выборка | Страница ленты (см. ниже). |
IsEmptySubscriptions | bool | Да | вычисляется | true — у актора нет активных подписок; Page.Items пуст. |
Страница ленты в keyset-режиме несёт Items, NextCursor (string?) и HasMore (bool); общее число
элементов для ленты не вычисляется (пагинация курсорная).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Id | Guid | Да | Post.Id | Идентификатор поста. |
Suffix | string | Да | Post.Suffix | URL-slug (/news/{suffix}). |
Title | string | Да | Post.Title | Заголовок. |
Subtitle | string? | Нет | Post.Subtitle | Подзаголовок. |
CoverFileId | Guid? | Нет | Post.CoverFileId | Обложка (storage/CDN). |
CategoryId | Guid | Да | Post.CategoryId | Категория. |
AuthorType | PostAuthorType (string) | Да | Post.AuthorType | Тип автора (enum-дом — post.md). |
AuthorUserId | Guid | Да | Post.AuthorUserId | Автор. |
AuthorDisplayName | string | Да | AuthorReplica.DisplayName | Подпись автора (батч). |
AuthorAvatarFileId | Guid? | Нет | AuthorReplica.AvatarFileId | Аватар. |
AuthorCompany | PostFeedAuthorCompany? | Нет | реплика (см. модель) | Компания-автор (nested ref-model); null — инвестор/сотрудник. |
CommentsCount | int | Да | Post.CommentsCount | Денорм. счётчик. |
ViewsCount | long | Да | Post.ViewsCount | Денорм. просмотры. |
PublishedAt | DateTimeOffset | Да | Post.PublishedAt | Момент публикации (ключ сортировки). |
FirstPublishedAt | DateTimeOffset? | Нет | Post.FirstPublishedAt | Первая публикация. |
PostFeedAuthorCompany (nested ref-model компании-автора; та же, что у глобальной ленты)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AuthorCompanyId | Guid | Да | Post.AuthorCompanyId | Идентификатор компании-автора. |
AuthorCompanyName | string | Да | CompanyReplica.Name | Название компании-автора. |
LogoFileId | Guid? | Нет | CompanyReplica.LogoFileId | Лого компании (storage/CDN). |
Алгоритм
- Контекст и доступ. Актор — авторизованный пользователь (
User); SubscriberUserId берётся из
JWT, не из query (INV-SUB-004). Гость без валидного JWT → UNAUTHORIZED (401). Лента личная, вне
tenant — изоляция по SubscriberUserId.
- Проверка входа.
Limit в диапазоне 1..30; CategoryId != Guid.Empty при задании; Cursor
(если задан) — корректный opaque-ключ (PublishedAt, Id), иначе VALIDATION_ERROR (400).
Существование CategoryId не проверяется — несуществующий id даёт пустую страницу (утечки нет).
- Загрузка целей подписки. Получить активные подписки актора (не удалённые), разбить на цели-авторов
и цели-компании. Если оба множества пусты →
IsEmptySubscriptions = true, Page — пустая страница
(Items = [], NextCursor = null, HasMore = false); дальнейшая выборка пропускается. Иначе
IsEmptySubscriptions = false.
- Выборка постов и маппинг. Страница постов keyset-ом от
Cursor: Status = Published, автор входит
в множество авторов-целей ИЛИ компания-автор входит в множество компаний-целей, опциональный фильтр по
CategoryId; посты компаний со статусом Suspended исключаются (INV-REP-003). Порядок —
PublishedAt desc, Id desc (стабильный keyset); читается на один элемент больше Limit для расчёта
HasMore. Пост автора со статусом Blocked остаётся в ленте, подпись помечается при отображении
(INV-REP-002 — правило выдачи, по умолчанию не скрывает). Подписи автора и компании собираются батчем
из локальных реплик (без синхронного вызова в identity) и маппятся поле-в-поле в PostFeedItem.
HasMore — был ли лишний элемент; NextCursor — ключ последнего оставленного при HasMore, иначе null.
- Результат.
SubscriptionFeedResult (Page, IsEmptySubscriptions) → 200. Пустые подписки или
конец ленты — 200 с Items = []. Свежесть подписей — eventual (лаг консюмеров identity).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
UNAUTHORIZED | 401 | JWT отсутствует/невалиден (гость) | «Требуется авторизация» | no retry (войти) |
VALIDATION_ERROR | 400 | Limit вне 1..30, CategoryId == Guid.Empty, невалидный Cursor | «Некорректные параметры запроса» | no retry |
INTERNAL_ERROR | 500 | Инфраструктурный сбой | generic | retry (backoff) |
Совместимость и наблюдаемость
- Новые optional-поля
PostFeedItem — additive; удаление/переименование/смена типа или сужение Limit —
breaking (changelog контекста). Enum PostAuthorType — строкой; клиентский fallback на unknown обязателен.
- Logs:
SubscriberUserId, Limit, наличие Cursor/CategoryId, число целей, размер страницы,
IsEmptySubscriptions; metrics: news_subscription_feed_requests_total{empty}, latency histogram
(SLO лента p99 < 300 мс); traces: сквозной trace через traceparent.
- Dashboard/runbook — news-overview (README).
Acceptance Criteria
- Лента по подпискам. Дано: у актора есть активные подписки на авторов и/или компании, у которых есть
Published-посты. Когда: User вызывает GET /api/v1/feed/subscriptions. Тогда: 200,
IsEmptySubscriptions = false, страница постов подписанных источников в порядке PublishedAt desc, Id desc;
при продолжении — NextCursor и HasMore = true.
- Нет подписок. Дано: у актора нет активных подписок. Когда:
GET. Тогда: 200,
IsEmptySubscriptions = true, Page.Items = [], NextCursor = null, HasMore = false (фронт показывает
приглашение подписаться).
- Гость. Дано: запрос без валидного JWT. Когда:
GET. Тогда: 401 (лента строго персональная).
- Только свои подписки. Дано: попытка подставить чужой идентификатор. Когда:
GET. Тогда: SubscriberUserId
берётся из JWT, не из query (INV-SUB-004) — чужая лента недоступна.
- Фильтр по категории. Дано: задан
CategoryId. Когда: GET. Тогда: только посты этой категории среди
подписанных источников; несуществующий CategoryId → пустая страница (не ошибка).
- Suspended/Blocked. Дано: среди целей есть
Suspended-компания и Blocked-автор. Когда: GET. Тогда:
посты Suspended-компании исключены (INV-REP-003), посты Blocked-автора остаются с помеченной подписью
(INV-REP-002).
- Некорректные параметры. Дано:
Limit вне 1..30, либо CategoryId == Guid.Empty, либо невалидный
Cursor. Когда: GET. Тогда: 400 VALIDATION_ERROR.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы