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

GET /api/v1/subscriptions — Мои подписки

Назначение

Вернуть аутентифицированному пользователю его собственный список активных подписок — экран управления подписками (отписаться, перейти к автору/компании). Совокупность этих подписок задаёт ленту subscription (list-subscription-feed.md); данный endpoint — не лента постов, а перечень строк подписки актора с денормализованной подписью цели (имя + лого/аватар из реплик identity, без синхронного джойна). Только чтение — без мутаций.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (аутентифицированный подписчик; guest — 401)
Auth policy / PermissionsJWT; отдельного permission нет — self-scope. Feature flag news.subscriptions.enabled
Scope (RBAC)не применяется (подписка вне tenant)
Record-levelвладелец = SubscriberUserId из JWT; чужие подписки не читаются (INV-SUB-004)
Tenant isolationне применяется — личные данные вне tenant; изоляция по SubscriberUserId
Auditnone (read собственных данных)

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
SubscriberUserIdidentity claims (JWT)GuidДаиз контекста актора, не из query (INV-SUB-004)record-level self-scope
Cursorquerystring?Нетbase64url SubscriptionsCursor (ContractVersion = 1); сбой парсинга / чужой ContractVersionNEWS_SUBSCRIPTION_CURSOR_INVALID (400)не секрет; подмена = другая страница своей выдачи
LimitqueryintНет (default 50)1..100; вне диапазона → VALIDATION_ERROR

Модель ответа — страница подписок (SubscriptionItem)

Выдача keyset (стабильна при параллельных subscribe/unsubscribe): заполняются Items, TotalCount (дешёвый count активных) и NextCursor (opaque продолжение; null — конец). Offset-поля не используются.

Вложенная модель SubscriptionItem

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаAuthorSubscription.IdИдентификатор подписки (стабилен при reactivate; для unsubscribe).
TargetKindSubscriptionTargetKindДаAuthorSubscription.TargetKind (enum строкой)Author / Company — тип источника.
TargetDisplayNamestringДаAuthor: AuthorReplica.DisplayName; Company: CompanyReplica.NameПодпись цели; при отсутствии реплики — плейсхолдер.
LogoFileIdGuid?НетAuthor: AuthorReplica.AvatarFileId; Company: CompanyReplica.LogoFileIdЛого/аватар цели (внешний storage/CDN, без FK); null — нет изображения.
CreatedAtDateTimeOffsetДаAuthorSubscription.CreatedAtДата первой подписки (сохраняется при reactivate); ключ сортировки.

Вложенная модель SubscriptionsCursor (typed, ContractVersion = 1)

ПолеТипОбязательностьОписание
ContractVersionintДа= 1; иное → NEWS_SUBSCRIPTION_CURSOR_INVALID.
LastCreatedAtDateTimeOffsetДаCreatedAt последней строки страницы (ключ keyset).
LastIdGuidДаId последней строки — тай-брейк keyset.

Алгоритм

  1. Контекст и доступ. Актор — авторизованный пользователь (User); SubscriberUserId — из JWT, не из query (INV-SUB-004). Метод вне tenant. Если Cursor задан — распарсить base64url в SubscriptionsCursor; сбой парсинга или ContractVersion != 1NEWS_SUBSCRIPTION_CURSOR_INVALID (400).
  2. Проверка входа. Limit в диапазоне 1..100 (default 50) → иначе VALIDATION_ERROR (400). Существование/состояние/связи не проверяются: SubscriberUserId — сам актор, целевые сущности на входе не читаются.
  3. Что читаем. Страница активных (не удалённых) подписок актора keyset-ом от Cursor, порядок CreatedAt desc, Id desc (тай-брейк по Id); читается на один элемент больше Limit для NextCursor/ HasMore. Дешёвый счётчик активных подписок → TotalCount. Подписи целей собираются батчем из локальных реплик (AuthorReplica для целей-авторов, CompanyReplica для целей-компаний) — без синхронного вызова в identity.
  4. Правила выдачи и маппинг. Мутаций/переходов нет. Маппинг поле-в-поле по таблице SubscriptionItem: для цели-автора — имя и аватар из AuthorReplica; для цели-компании — название и лого из CompanyReplica. Реплика не найдена (лаг консюмера identity) → TargetDisplayName = "(имя обновляется)", LogoFileId = null (мягкая деградация; freshness eventual). Цели со статусом Blocked/Suspended остаются в списке — владелец видит свои подписки, чтобы отписаться; фильтр Blocked/Suspended (INV-REP-002/003) — правило выдачи ленты, не списка подписок.
  5. Результат. При HasMore из последней строки страницы собирается SubscriptionsCursor (ContractVersion = 1, LastCreatedAt, LastId) → base64url; иначе NextCursor = null. Страница подписок (Items, TotalCount, NextCursor, HasMore) → HTTP 200 (пусто → Items = [], TotalCount = 0, NextCursor = null).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_ERROR400Limit вне [1..100]«Некорректные параметры запроса»no retry
NEWS_SUBSCRIPTION_CURSOR_INVALID400Курсор не парсится / чужой ContractVersion«Курсор устарел — обновите список»retry без cursor (первая страница)
INTERNAL_ERROR500Инфраструктурный сбойgenericretry (backoff)

Guest без JWT → 401 (пайплайн авторизации, до бизнес-логики).

Совместимость и наблюдаемость

  • Новые optional-поля SubscriptionItem и параметры запроса — additive; смена default Limit, семантики keyset или формата SubscriptionsCursor — breaking (bump ContractVersion курсора + rollout).
  • TargetKind — enum строкой; клиентский fallback на unknown обязателен (additive-значения).
  • Logs: subscriptions-listed (SubscriberUserId, count, HasMore; имена/лого не логируются); metrics: news_subscriptions_list_total, latency histogram (SLO чтение p99 < 200 мс); traces: сквозной trace через traceparent; dashboard/runbook — news-overview (README.md).

Acceptance Criteria

  • Список подписок. Дано: у актора есть активные подписки. Когда: User вызывает GET /api/v1/subscriptions. Тогда: 200, Items в порядке CreatedAt desc, Id desc, TotalCount = число активных подписок; при продолжении — непустой NextCursor и HasMore = true.
  • Пусто. Дано: активных подписок нет. Когда: GET. Тогда: 200, Items = [], TotalCount = 0, NextCursor = null.
  • Гость. Дано: запрос без JWT. Когда: GET. Тогда: 401.
  • Только свои. Дано: попытка прочитать чужой список. Когда: GET. Тогда: SubscriberUserId берётся из JWT (INV-SUB-004) — чужие подписки не читаются.
  • Лаг реплики цели. Дано: реплика identity цели ещё не догнала. Когда: GET. Тогда: TargetDisplayName = "(имя обновляется)", LogoFileId = null (мягкая деградация), строка всё равно в выдаче.
  • Blocked/Suspended цели видимы. Дано: среди целей есть Blocked-автор или Suspended-компания. Когда: GET. Тогда: они остаются в списке — владелец должен иметь возможность отписаться (фильтр Blocked/Suspended — правило ленты, не списка подписок).
  • Некорректный лимит. Дано: Limit вне [1..100]. Когда: GET. Тогда: 400 VALIDATION_ERROR.
  • Битый курсор. Дано: Cursor не парсится или чужой ContractVersion. Когда: GET. Тогда: 400 NEWS_SUBSCRIPTION_CURSOR_INVALID; повтор без cursor даёт первую страницу.

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

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

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