POST /api/v1/subscriptions — Подписаться на автора/компанию
Назначение
Создать личную подписку пользователя-подписчика на источник новостей — автора-пользователя (агент,
инвестор-блогер) или компанию (застройщик/агентство). Совокупность активных подписок задаёт ленту
subscription (list-subscription-feed.md). Операция
идемпотентна-toggle: повторная подписка на ту же цель после отписки — reactivate той же строки
(soft-deleted → Active), а не новая запись (INV-SUB-001).
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | subscriptions |
| Статус | draft |
| Документ | docs/06-services/news/api/public/subscriptions/subscribe.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 | create/update (actor-stamping = SubscriberUserId, INV-SUB-004) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/subscriptions |
| Success status | 201 (создана новая строка) / 200 (reactivate soft-deleted или уже активна — идемпотентно) |
| Idempotency header | не применяется — бизнес-идемпотентность по уникальности (подписчик, kind, цель) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
SubscriberUserId | identity claims (JWT) | Guid | Да | из контекста актора, не из тела (INV-SUB-004) | — |
TargetKind | body | SubscriptionTargetKind | Да | enum строкой Author / Company; неизвестное значение → NEWS_SUBSCRIPTION_TARGET_INVALID | — |
TargetUserId | body | Guid? | Условно (при Author) | XOR по kind (INV-SUB-002); != Guid.Empty; логическая ссылка на пользователя identity (синхронно не проверяется) | — |
TargetCompanyId | body | Guid? | Условно (при Company) | XOR по kind (INV-SUB-002); != Guid.Empty; логическая ссылка на компанию identity (синхронно не проверяется) | — |
TargetKind детально — enum SubscriptionTargetKind в author-subscription.md.
Модель ответа SubscribeResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
SubscriptionId | Guid | Да | AuthorSubscription.Id | — | Идентификатор подписки (стабилен при reactivate). |
TargetKind | SubscriptionTargetKind | Да | AuthorSubscription.TargetKind (enum строкой) | — | Тип источника: Author / Company. |
Target | SubscriptionTargetRef | Да | computed из строки | — | Типизированная ссылка на цель (XOR по kind). |
Вложенная модель SubscriptionTargetRef
| Поле | Тип | Обязательность | Источник | Описание |
|---|
TargetUserId | Guid? | Условно (при Author) | AuthorSubscription.TargetUserId | Автор-пользователь; null при Company. |
TargetCompanyId | Guid? | Условно (при Company) | AuthorSubscription.TargetCompanyId | Компания; null при Author. |
Алгоритм
- Контекст и доступ. Актор — авторизованный пользователь (
User); SubscriberUserId — из JWT, не из
тела (INV-SUB-004). Идемпотентность бизнесовая (по уникальности цели), отдельного ключа идемпотентности нет.
- Проверка входа.
TargetKind ∈ {Author, Company} — иначе NEWS_SUBSCRIPTION_TARGET_INVALID;
- XOR цели по kind (INV-SUB-002): при
Author задан TargetUserId и пуст TargetCompanyId; при
Company — зеркально; нарушение → NEWS_SUBSCRIPTION_TARGET_INVALID;
- не-self (INV-SUB-003): при
Author подписчик не может подписаться на себя (SubscriberUserId != TargetUserId); иначе NEWS_SUBSCRIPTION_SELF;
- существование цели в
identity синхронно не проверяется (реплики eventual, битая ссылка
отфильтруется при сборке ленты).
- Что читаем. По естественному ключу цели (
SubscriberUserId, TargetKind, идентификатор цели) найти
существующую строку подписки, включая soft-deleted — чтобы выбрать ветку create / reactivate / no-op.
- Правила и переходы.
- нет строки → создать подписку: новый идентификатор,
CreatedAt = UpdatedAt = now, DeletedAt = null,
actor-stamping = SubscriberUserId; переход [*] → Active; результат — создание (201).
- строка soft-deleted → reactivate той же строки: снять
DeletedAt, обновить UpdatedAt и
actor-stamping; CreatedAt (дата первой подписки) сохраняется; переход Deleted → Active (INV-SUB-001);
результат — идемпотентно (200).
- строка активна → идемпотентный no-op, состояние не меняется; результат —
200.
- Ответ:
SubscriptionId, TargetKind, Target (типизированная ссылка XOR по kind) из строки.
- Согласованность и результат. Изменение одной строки подписки применяется атомарно (интеграционных
событий нет). Гонка параллельной вставки нарушает уникальность (подписчик, kind, цель)
(
NEWS_SUBSCRIPTION_ALREADY_EXISTS, INV-SUB-001) → перечитать строку и вернуть идемпотентный 200.
Итог: 201, если строка создана; иначе 200 (reactivate / уже активна).
Событий операция не публикует: подписка — read-side состояние, downstream на «факт подписки» отсутствует
(author-subscription.md); аналитический клик уходит фронтом в
user-activity (ADR-0054), не write-путём news.
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
NEWS_SUBSCRIPTION_TARGET_INVALID | 400 | нарушен XOR цели/kind или неизвестный TargetKind (INV-SUB-002) | «Некорректная цель подписки» | no retry |
NEWS_SUBSCRIPTION_SELF | 400 | Author и SubscriberUserId == TargetUserId (INV-SUB-003) | «Нельзя подписаться на себя» | no retry |
NEWS_SUBSCRIPTION_ALREADY_EXISTS | — (внутр.) | гонка вставки: нарушение уникальности (подписчик, kind, цель) (INV-SUB-001) | — | обрабатывается: перечитка строки → идемпотентный 200 |
Guest без JWT → 401 (пайплайн авторизации, до бизнес-логики).
Совместимость и наблюдаемость
TargetKind — versioned enum строкой; новое значение — additive (unknown → NEWS_SUBSCRIPTION_TARGET_INVALID).
Удаление/переименование полей ответа — breaking (migration + rollout).
- Logs:
subscription created/reactivated/noop (SubscriptionId, SubscriberUserId, TargetKind, target);
metrics: news_subscriptions_total{result=created|reactivated|noop|rejected}, latency p95; traces —
сквозной trace через traceparent; dashboard news-overview; runbook — docs/06-services/news/runbooks/.
Acceptance Criteria
- Новая подписка. Дано: строки на пару (
SubscriberUserId, TargetKind, цель) нет. Когда: User шлёт
POST с TargetKind = Author и TargetUserId. Тогда: 201, переход [*] → Active, SubscribeResult с
типизированной ссылкой на цель.
- Reactivate. Дано: строка на ту же цель soft-удалена. Когда: повторный
POST. Тогда: 200, переход
Deleted → Active, DeletedAt снят, CreatedAt (дата первой подписки) сохранён, SubscriptionId тот же
(INV-SUB-001).
- Уже активна. Дано: активная строка на цель. Когда:
POST. Тогда: 200, идемпотентный no-op, состояние
не меняется.
- XOR нарушен. Дано:
TargetKind = Author, но задан TargetCompanyId (или оба/ни одного). Когда: POST.
Тогда: 400 NEWS_SUBSCRIPTION_TARGET_INVALID (INV-SUB-002).
- Неизвестный kind. Дано:
TargetKind вне {Author, Company}. Когда: POST. Тогда:
400 NEWS_SUBSCRIPTION_TARGET_INVALID.
- Подписка на себя. Дано:
TargetKind = Author, TargetUserId == SubscriberUserId. Когда: POST. Тогда:
400 NEWS_SUBSCRIPTION_SELF (INV-SUB-003).
- Гость. Дано: запрос без JWT. Когда:
POST. Тогда: 401.
- Гонка вставки. Дано: два параллельных
POST на одну новую цель. Когда: оба выполняются. Тогда: один
создаёт строку, второй ловит нарушение уникальности (INV-SUB-001) → перечитывает строку и возвращает
идемпотентный 200; дубль-строки нет.
- Цель не проверяется синхронно. Дано:
TargetUserId/TargetCompanyId не существует в identity. Когда:
POST. Тогда: подписка создаётся (реплики eventual); битая ссылка отфильтруется при сборке ленты.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы