DELETE /api/v1/subscriptions/{id} — Отписаться от источника
Назначение
Отписка пользователя-подписчика от источника новостей (автора или компании) в сервисе news. Отписка —
soft-delete записи AuthorSubscription (DeletedAt = now): цель исчезает из выборки целей ленты
subscription, история сохраняется для аналитики оттока, повторная подписка reactivate-ит ту же строку
(author-subscription.md, переходы Active ↔ Deleted). Операция
идемпотентна: повторная отписка (или отписка уже soft-удалённой строки) — no-op, тот же 204.
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | subscriptions |
| Статус | draft |
| Документ | docs/06-services/news/api/public/subscriptions/unsubscribe.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (аутентифицированный подписчик, JWT identity) |
| Auth policy / Permissions | JWT обязателен; отдельного permission нет — self-scope подписок (README) |
| Record-level | владелец-подписчик: subscription.SubscriberUserId == actor.SubscriberUserId (INV-SUB-004) → иначе 403 |
| Tenant isolation | не применяется — вне tenant; изоляция по SubscriberUserId из JWT, не по TenantId |
| Audit | delete (soft): DeletedByUserId = SubscriberUserId, DeletedAt/UpdatedAt |
HTTP-контракт
| Поле | Значение |
|---|
| Method | DELETE |
| Route | /api/v1/subscriptions/{id} |
| Success status | 204 No Content (в т.ч. идемпотентный повтор — уже отписан) |
| Idempotency | операция естественно идемпотентна (повтор = no-op, шаг 2) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
Id | route | Guid | Да | Id != Guid.Empty → иначе VALIDATION_FAILED | — |
SubscriberUserId | identity claims (JWT) | Guid | Да | Required; берётся из контекста актора, не из тела (INV-SUB-004) | record-level owner check |
Модель ответа
Успех возвращает 204 No Content без тела. Ошибки — ProblemDetails (см. «Ошибки»).
Алгоритм
- Контекст и доступ. Актор — аутентифицированный
User; SubscriberUserId берётся из JWT-claims
identity (не из тела запроса). Операция вне tenant. Идемпотентность обеспечивается состоянием строки
(шаг 2), отдельного idempotency-ключа не требуется.
- Проверка входа и предусловий.
- формат:
Id != Guid.Empty → иначе VALIDATION_FAILED (400);
- существование: искомая подписка — по
Id, включая soft-удалённые (чтобы поймать уже отписанную
строку для идемпотентности); нет строки → NEWS_SUBSCRIPTION_NOT_FOUND (404);
- record-level (INV-SUB-004):
subscription.SubscriberUserId == SubscriberUserId; иначе
NEWS_SUBSCRIPTION_FORBIDDEN (403) — чужую подписку трогать нельзя;
- допустимость состояния: если подписка уже soft-удалена (
DeletedAt != null) — идемпотентный no-op,
сразу шаг 4 (204).
- Переход.
Active → Deleted: DeletedAt = now, UpdatedAt = now, DeletedByUserId = SubscriberUserId
(actor-stamping). Физического удаления нет — retention истории отписок для аналитики оттока. Подписка не
публикует ни доменных, ни интеграционных событий (author-subscription.md, «Событий нет»); аналитический
клик отписки шлёт фронт напрямую в user-activity как ActionEvent (ADR-0054),
не write-путём news.
- Результат.
204 No Content без тела. Идемпотентный повтор (no-op из шага 2) даёт тот же 204.
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | Id == Guid.Empty | «Некорректный идентификатор подписки» | no retry |
NEWS_SUBSCRIPTION_NOT_FOUND | 404 | подписки с Id не существует | «Подписка не найдена» | no retry |
NEWS_SUBSCRIPTION_FORBIDDEN | 403 | subscription.SubscriberUserId != actor (INV-SUB-004, чужая подписка) | «Недостаточно прав» | no retry |
Уже soft-удалённая своя подписка — не ошибка: идемпотентный 204 (шаг 2).
Совместимость и наблюдаемость
- Контракт стабилен: маршрут и
204-семантика не меняются без версии API; идемпотентность гарантирована
(клиент может безопасно повторять DELETE). Feature flag news.subscriptions.enabled (README).
- Logs:
subscription-unsubscribed (Id, SubscriberUserId) / subscription-unsubscribe-noop (повтор);
metrics: news_subscriptions_unsubscribe_total{result}; traces — сквозной span через traceparent;
dashboard news-overview. PII нет — только id-ссылки.
Acceptance Criteria
- Отписка. Дано: у актора есть активная подписка
Id. Когда: User шлёт DELETE /api/v1/subscriptions/{id}.
Тогда: переход Active → Deleted (soft, DeletedAt = now, DeletedByUserId = SubscriberUserId), 204 без
тела; цель исчезает из выборки ленты subscription, история сохраняется.
- Идемпотентный повтор. Дано: своя подписка уже soft-удалена. Когда: повторный
DELETE. Тогда: 204
no-op — без повторного эффекта.
- Не найдена. Дано: подписки с
Id не существует. Когда: DELETE. Тогда: 404 NEWS_SUBSCRIPTION_NOT_FOUND.
- Чужая подписка. Дано: строка принадлежит другому подписчику. Когда:
DELETE. Тогда:
403 NEWS_SUBSCRIPTION_FORBIDDEN (INV-SUB-004) — состояние не меняется.
- Некорректный id. Дано:
Id == Guid.Empty. Когда: DELETE. Тогда: 400 VALIDATION_FAILED.
- Гость. Дано: запрос без JWT. Когда:
DELETE. Тогда: 401.
- Конкурентность. Дано: два параллельных
DELETE одной строки — или DELETE одновременно с reactivate
(subscribe). Когда: оба выполняются. Тогда: каждый DELETE идемпотентен (204), soft-delete фиксируется
один раз; при гонке с reactivate побеждает последний писатель DeletedAt, строка сходится в детерминированное
состояние без дубля.
- Событий нет. Дано: успешная отписка. Когда: строка soft-удалена. Тогда: ни доменных, ни интеграционных
событий не публикуется; аналитический клик отписки шлёт фронт →
user-activity (ADR-0054).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы