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

GET /api/v1/feed/subscriptions — Лента по подпискам

Назначение

Персональная лента новостей авторизованного пользователя: посты авторов и компаний, на которых он подписан. Это один из двух режимов ленты (второй — глобальный, подписок не требует). Подписка здесь — фильтр сборки, а не push-канал (author-subscription.md). Если у пользователя нет активных подписок, лента возвращает пустую страницу с флагом IsEmptySubscriptions, чтобы фронт показал приглашение подписаться. Только чтение — без мутаций и событий.

Metadata

ПолеЗначение
Сервис/контекстnews
API areapublic
Feature groupfeed
Статусdraft
OwnerPIN Engagement
Документdocs/06-services/news/api/public/feed/list-subscription-feed.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (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
Auditnone (read); клик/просмотр — фронт → user-activity (ADR-0054)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/feed/subscriptions
Success status200
Idempotency headerне применяется (GET)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
SubscriberUserIdidentity claimsGuidДа (вычисляется)из JWT; отсутствует/невалиден → 401не из query (INV-SUB-004)
Cursorquerystring?Нетopaque base64url ключа (PublishedAt, Id) последнего элемента; невалидный формат → 400; null — первая страница
LimitqueryintНет (default 20)1..30
CategoryIdqueryGuid?Нет!= Guid.Empty при задании; существование не проверяется (несуществующий id → пустая выдача)

Модель ответа SubscriptionFeedResult

ПолеТипОбязательностьИсточникОписание
Pageстраница ленты (PostFeedItem)Даkeyset-выборкаСтраница ленты (см. ниже).
IsEmptySubscriptionsboolДавычисляетсяtrue — у актора нет активных подписок; Page.Items пуст.

Страница ленты в keyset-режиме несёт Items, NextCursor (string?) и HasMore (bool); общее число элементов для ленты не вычисляется (пагинация курсорная).

Вложенная модель PostFeedItem (проекция post.md + identity-replicas.md)

ПолеТипОбязательностьИсточникОписание
IdGuidДаPost.IdИдентификатор поста.
SuffixstringДаPost.SuffixURL-slug (/news/{suffix}).
TitlestringДаPost.TitleЗаголовок.
Subtitlestring?НетPost.SubtitleПодзаголовок.
CoverFileIdGuid?НетPost.CoverFileIdОбложка (storage/CDN).
CategoryIdGuidДаPost.CategoryIdКатегория.
AuthorTypePostAuthorType (string)ДаPost.AuthorTypeТип автора (enum-дом — post.md).
AuthorUserIdGuidДаPost.AuthorUserIdАвтор.
AuthorDisplayNamestringДаAuthorReplica.DisplayNameПодпись автора (батч).
AuthorAvatarFileIdGuid?НетAuthorReplica.AvatarFileIdАватар.
AuthorCompanyPostFeedAuthorCompany?Нетреплика (см. модель)Компания-автор (nested ref-model); null — инвестор/сотрудник.
CommentsCountintДаPost.CommentsCountДенорм. счётчик.
ViewsCountlongДаPost.ViewsCountДенорм. просмотры.
PublishedAtDateTimeOffsetДаPost.PublishedAtМомент публикации (ключ сортировки).
FirstPublishedAtDateTimeOffset?НетPost.FirstPublishedAtПервая публикация.

PostFeedAuthorCompany (nested ref-model компании-автора; та же, что у глобальной ленты)

ПолеТипОбязательностьИсточникОписание
AuthorCompanyIdGuidДаPost.AuthorCompanyIdИдентификатор компании-автора.
AuthorCompanyNamestringДаCompanyReplica.NameНазвание компании-автора.
LogoFileIdGuid?НетCompanyReplica.LogoFileIdЛого компании (storage/CDN).

Алгоритм

  1. Контекст и доступ. Актор — авторизованный пользователь (User); SubscriberUserId берётся из JWT, не из query (INV-SUB-004). Гость без валидного JWT → UNAUTHORIZED (401). Лента личная, вне tenant — изоляция по SubscriberUserId.
  2. Проверка входа. Limit в диапазоне 1..30; CategoryId != Guid.Empty при задании; Cursor (если задан) — корректный opaque-ключ (PublishedAt, Id), иначе VALIDATION_ERROR (400). Существование CategoryId не проверяется — несуществующий id даёт пустую страницу (утечки нет).
  3. Загрузка целей подписки. Получить активные подписки актора (не удалённые), разбить на цели-авторов и цели-компании. Если оба множества пусты → IsEmptySubscriptions = true, Page — пустая страница (Items = [], NextCursor = null, HasMore = false); дальнейшая выборка пропускается. Иначе IsEmptySubscriptions = false.
  4. Выборка постов и маппинг. Страница постов 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.
  5. Результат. SubscriptionFeedResult (Page, IsEmptySubscriptions) → 200. Пустые подписки или конец ленты — 200 с Items = []. Свежесть подписей — eventual (лаг консюмеров identity).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
UNAUTHORIZED401JWT отсутствует/невалиден (гость)«Требуется авторизация»no retry (войти)
VALIDATION_ERROR400Limit вне 1..30, CategoryId == Guid.Empty, невалидный Cursor«Некорректные параметры запроса»no retry
INTERNAL_ERROR500Инфраструктурный сбойgenericretry (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.

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

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

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