GET /api/v1/subscriptions — Мои подписки
Назначение
Вернуть аутентифицированному пользователю его собственный список активных подписок — экран управления
подписками (отписаться, перейти к автору/компании). Совокупность этих подписок задаёт ленту subscription
(list-subscription-feed.md); данный endpoint — не лента постов, а
перечень строк подписки актора с денормализованной подписью цели (имя + лого/аватар из реплик identity,
без синхронного джойна). Только чтение — без мутаций.
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | subscriptions |
| Статус | draft |
| Owner | PIN Engagement |
| Документ | docs/06-services/news/api/public/subscriptions/list-subscriptions.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (аутентифицированный подписчик; guest — 401) |
| Auth policy / Permissions | JWT; отдельного permission нет — self-scope. Feature flag news.subscriptions.enabled |
| Scope (RBAC) | не применяется (подписка вне tenant) |
| Record-level | владелец = SubscriberUserId из JWT; чужие подписки не читаются (INV-SUB-004) |
| Tenant isolation | не применяется — личные данные вне tenant; изоляция по SubscriberUserId |
| Audit | none (read собственных данных) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/subscriptions |
| Success status | 200 |
| Idempotency header | не применяется (GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
SubscriberUserId | identity claims (JWT) | Guid | Да | из контекста актора, не из query (INV-SUB-004) | record-level self-scope |
Cursor | query | string? | Нет | base64url SubscriptionsCursor (ContractVersion = 1); сбой парсинга / чужой ContractVersion → NEWS_SUBSCRIPTION_CURSOR_INVALID (400) | не секрет; подмена = другая страница своей выдачи |
Limit | query | int | Нет (default 50) | 1..100; вне диапазона → VALIDATION_ERROR | — |
Модель ответа — страница подписок (SubscriptionItem)
Выдача keyset (стабильна при параллельных subscribe/unsubscribe): заполняются Items, TotalCount
(дешёвый count активных) и NextCursor (opaque продолжение; null — конец). Offset-поля не используются.
Вложенная модель SubscriptionItem
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | AuthorSubscription.Id | — | Идентификатор подписки (стабилен при reactivate; для unsubscribe). |
TargetKind | SubscriptionTargetKind | Да | AuthorSubscription.TargetKind (enum строкой) | — | Author / Company — тип источника. |
TargetDisplayName | string | Да | Author: AuthorReplica.DisplayName; Company: CompanyReplica.Name | — | Подпись цели; при отсутствии реплики — плейсхолдер. |
LogoFileId | Guid? | Нет | Author: AuthorReplica.AvatarFileId; Company: CompanyReplica.LogoFileId | — | Лого/аватар цели (внешний storage/CDN, без FK); null — нет изображения. |
CreatedAt | DateTimeOffset | Да | AuthorSubscription.CreatedAt | — | Дата первой подписки (сохраняется при reactivate); ключ сортировки. |
Вложенная модель SubscriptionsCursor (typed, ContractVersion = 1)
| Поле | Тип | Обязательность | Описание |
|---|
ContractVersion | int | Да | = 1; иное → NEWS_SUBSCRIPTION_CURSOR_INVALID. |
LastCreatedAt | DateTimeOffset | Да | CreatedAt последней строки страницы (ключ keyset). |
LastId | Guid | Да | Id последней строки — тай-брейк keyset. |
Алгоритм
- Контекст и доступ. Актор — авторизованный пользователь (
User); SubscriberUserId — из JWT, не из
query (INV-SUB-004). Метод вне tenant. Если Cursor задан — распарсить base64url в SubscriptionsCursor;
сбой парсинга или ContractVersion != 1 → NEWS_SUBSCRIPTION_CURSOR_INVALID (400).
- Проверка входа.
Limit в диапазоне 1..100 (default 50) → иначе VALIDATION_ERROR (400).
Существование/состояние/связи не проверяются: SubscriberUserId — сам актор, целевые сущности на входе
не читаются.
- Что читаем. Страница активных (не удалённых) подписок актора keyset-ом от
Cursor, порядок
CreatedAt desc, Id desc (тай-брейк по Id); читается на один элемент больше Limit для NextCursor/
HasMore. Дешёвый счётчик активных подписок → TotalCount. Подписи целей собираются батчем из локальных
реплик (AuthorReplica для целей-авторов, CompanyReplica для целей-компаний) — без синхронного вызова
в identity.
- Правила выдачи и маппинг. Мутаций/переходов нет. Маппинг поле-в-поле по таблице
SubscriptionItem:
для цели-автора — имя и аватар из AuthorReplica; для цели-компании — название и лого из CompanyReplica.
Реплика не найдена (лаг консюмера identity) → TargetDisplayName = "(имя обновляется)",
LogoFileId = null (мягкая деградация; freshness eventual). Цели со статусом Blocked/Suspended
остаются в списке — владелец видит свои подписки, чтобы отписаться; фильтр Blocked/Suspended
(INV-REP-002/003) — правило выдачи ленты, не списка подписок.
- Результат. При
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_ERROR | 400 | Limit вне [1..100] | «Некорректные параметры запроса» | no retry |
NEWS_SUBSCRIPTION_CURSOR_INVALID | 400 | Курсор не парсится / чужой ContractVersion | «Курсор устарел — обновите список» | retry без cursor (первая страница) |
INTERNAL_ERROR | 500 | Инфраструктурный сбой | generic | retry (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 даёт первую страницу.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы