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

POST /api/v1/subscriptions — Подписаться на автора/компанию

Назначение

Создать личную подписку пользователя-подписчика на источник новостей — автора-пользователя (агент, инвестор-блогер) или компанию (застройщик/агентство). Совокупность активных подписок задаёт ленту subscription (list-subscription-feed.md). Операция идемпотентна-toggle: повторная подписка на ту же цель после отписки — reactivate той же строки (soft-deleted → Active), а не новая запись (INV-SUB-001).

Metadata

ПолеЗначение
Сервис/контекстnews
API areapublic
Feature groupsubscriptions
Статусdraft
Документdocs/06-services/news/api/public/subscriptions/subscribe.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
Auditcreate/update (actor-stamping = SubscriberUserId, INV-SUB-004)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/subscriptions
Success status201 (создана новая строка) / 200 (reactivate soft-deleted или уже активна — идемпотентно)
Idempotency headerне применяется — бизнес-идемпотентность по уникальности (подписчик, kind, цель)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
SubscriberUserIdidentity claims (JWT)GuidДаиз контекста актора, не из тела (INV-SUB-004)
TargetKindbodySubscriptionTargetKindДаenum строкой Author / Company; неизвестное значение → NEWS_SUBSCRIPTION_TARGET_INVALID
TargetUserIdbodyGuid?Условно (при Author)XOR по kind (INV-SUB-002); != Guid.Empty; логическая ссылка на пользователя identity (синхронно не проверяется)
TargetCompanyIdbodyGuid?Условно (при Company)XOR по kind (INV-SUB-002); != Guid.Empty; логическая ссылка на компанию identity (синхронно не проверяется)

TargetKind детально — enum SubscriptionTargetKind в author-subscription.md.

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

ПолеТипОбязательностьИсточникMaskingОписание
SubscriptionIdGuidДаAuthorSubscription.IdИдентификатор подписки (стабилен при reactivate).
TargetKindSubscriptionTargetKindДаAuthorSubscription.TargetKind (enum строкой)Тип источника: Author / Company.
TargetSubscriptionTargetRefДаcomputed из строкиТипизированная ссылка на цель (XOR по kind).

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

ПолеТипОбязательностьИсточникОписание
TargetUserIdGuid?Условно (при Author)AuthorSubscription.TargetUserIdАвтор-пользователь; null при Company.
TargetCompanyIdGuid?Условно (при Company)AuthorSubscription.TargetCompanyIdКомпания; null при Author.

Алгоритм

  1. Контекст и доступ. Актор — авторизованный пользователь (User); SubscriberUserId — из JWT, не из тела (INV-SUB-004). Идемпотентность бизнесовая (по уникальности цели), отдельного ключа идемпотентности нет.
  2. Проверка входа.
    • 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, битая ссылка отфильтруется при сборке ленты).
  3. Что читаем. По естественному ключу цели (SubscriberUserId, TargetKind, идентификатор цели) найти существующую строку подписки, включая soft-deleted — чтобы выбрать ветку create / reactivate / no-op.
  4. Правила и переходы.
    • нет строки → создать подписку: новый идентификатор, 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) из строки.
  5. Согласованность и результат. Изменение одной строки подписки применяется атомарно (интеграционных событий нет). Гонка параллельной вставки нарушает уникальность (подписчик, 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_INVALID400нарушен XOR цели/kind или неизвестный TargetKind (INV-SUB-002)«Некорректная цель подписки»no retry
NEWS_SUBSCRIPTION_SELF400Author и 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); битая ссылка отфильтруется при сборке ленты.

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

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

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