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

POST /api/v1/internal/snapshots/{id}/detach — Отвязать snapshot (замена блока целиком, m2m news)

Перевод SelectionSnapshot из Active в Detached. Единственный способ «изменить» блок-подборку опубликованного поста (INV-SEL-001): автор поста заменяет блок целиком — создаётся новый snapshot, старый отвязывается этим методом. Второй сценарий — удаление поста в news.

Назначение

Snapshot иммутабелен (INV-SEL-001): ни владелец подборки, ни автор поста не редактируют его Content. Когда автор поста хочет показать другой состав, news оркестрирует замену: сначала снимает новый snapshot (create-selection-snapshot.md) и записывает его id в блок поста, затем отвязывает прежний (DetachReason = BlockReplaced). Отвязанный snapshot не удаляется никогда (INV-SNP-006) — история версий поста в news продолжает на него ссылаться. Дом полей — ../domain/selection-snapshot.md.

Metadata

ПолеЗначение
Сервис/контекстselections
API areainternal
Feature groupsnapshots
Статусdraft
Документdocs/06-services/selections/api/internal/detach-selection-snapshot.md

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

ПроверкаЗначение
Actor typeExternalSystem — m2m-клиент news (INV-SNP-003)
Auth policy / Permissionsauthorization policy InternalNewsOnly
Scope (RBAC)не применяется; право автора менять блок поста проверяет news до вызова
Record-levelне применяется (доверенный m2m)
Tenant isolationне применяется — вне tenant (ADR-0052 п.3)
AuditDetachedAt, DetachReason, UpdatedAt на snapshot-е; лог с RequestedByUserId

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/internal/snapshots/{id:guid}/detach
Success status200 (в т.ч. идемпотентный повтор по уже Detached)
Idempotency headerне нужен: переход Active → Detached однократен, повтор — no-op (INV-SNP-005)
Correlationсквозной trace через заголовок traceparent, из news

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
SnapshotIdroute ({id})GuidДа!= Guid.Empty; snapshot существует (GetById)
ReasonbodySnapshotDetachReasonДа∈ {BlockReplaced, PostDeleted} (enum, хранится как text)trusted (m2m)
RequestedByUserIdbodyGuidДа!= Guid.Empty — автор поста / инициатор удаленияаудит-поле

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

ПолеТипОбязательностьИсточникMaskingОписание
SnapshotIdGuidДаSelectionSnapshot.IdEcho.
Statusstring (enum SnapshotStatus)ДаSelectionSnapshot.StatusВсегда Detached после успеха.
DetachedAtDateTimeOffsetДаSelectionSnapshot.DetachedAtМомент отвязки (первичной — при идемпотентном повторе возвращается исходный).
DetachReasonstring (enum SnapshotDetachReason)ДаSelectionSnapshot.DetachReasonПричина первичной отвязки (повтор с другой причиной её не перезаписывает).

Алгоритм

  1. Контекст и доступ. Доверенный m2m news (INV-SNP-003); отдельная проверка идемпотентности не нужна — она встроена в переход (шаг 3).
  2. Проверка входа и предусловий. Вызыватель — news (INV-SNP-003), иначе 403. SnapshotId непустой; Reason ∈ enum (неизвестное значение → VALIDATION_ERROR); RequestedByUserId непустой. Snapshot должен существовать, иначе SELECTIONS_SNAPSHOT_NOT_FOUND (404).
  3. Бизнес-правила и переходы. Отвязка (контент не читается и не меняется, INV-SEL-001):
    • статус Active → переход Active → Detached: проставляются DetachedAt, DetachReason, UpdatedAt; фиксируется факт SelectionSnapshotDetachedEvent;
    • статус Detached → идемпотентный no-op (INV-SNP-005): поля не меняются (включая DetachReason — история первичной причины сохраняется), события нет, результат — успех с текущим состоянием.
  4. Что меняется. Только при фактическом переходе — обновление snapshot-а в одном согласованном изменении. Конфликт с гонкой «замена блока» × «удаление поста» → проигравший перечитывает и попадает в идемпотентную ветку шага 3 (один внутренний повтор).
  5. Результат. SnapshotId, Status = Detached, DetachedAt, DetachReason200.

Диаграмма (полный флоу замены блока целиком, INV-SEL-001)

Побочные эффекты

ТипДетали
Проекции / поискContent snapshot-а не изменяется (INV-SEL-001), читается историей постов news и после отвязки

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_ERROR400Пустые Guid / Reason вне enum«Некорректный запрос»no retry
SELECTIONS_SNAPSHOT_NOT_FOUND404Snapshot не существует«Snapshot не найден»no retry
FORBIDDEN403Вызыватель не m2m news (INV-SNP-003)no retry

Повтор при сетевом сбое безопасен (INV-SNP-005); news может вызывать detach в saga удаления поста любое число раз.

Кросс-сервисный контракт (selectionsnews)

  • selections ожидает от news: валидный m2m токен (policy InternalNewsOnly); SnapshotId, Reason ∈ {BlockReplaced, PostDeleted} и RequestedByUserId. news вызывает detach как шаг оркестрации (замена блока целиком или удаление поста); порядок «создать новый → переключить блок → отвязать старый» — ответственность news.
  • selections предоставляет news: гарантию однократного перехода Active → Detached (INV-SNP-005) с идемпотентным повтором; snapshot никогда не удаляется (INV-SNP-006) — история версий поста в news остаётся разрешимой. Content при отвязке не читается и не меняется (INV-SEL-001).
  • Гонки и восстановление: конфликт «замена блока» × «удаление поста» разрешается оптимистической блокировкой — проигравший перечитывает и попадает в идемпотентную ветку; осиротевший Active-дубль при сбое между шагами убирает повторный detach-вызов news.

Предусловия и постусловия

  • Предусловия: вызыватель — доверенный m2m news; SnapshotId непустой; Reason ∈ enum; snapshot существует.
  • Постусловия при фактическом переходе (Active → Detached): у snapshot-а проставлены Status = Detached, DetachedAt, DetachReason, UpdatedAt; зафиксирован факт SelectionSnapshotDetachedEvent; Content не изменён (INV-SEL-001); строка сохранена (INV-SNP-006).
  • Постусловия при идемпотентном повторе (уже Detached): поля не изменяются (включая исходный DetachReason), событий нет, ответ — успех с текущим состоянием.
  • Постусловия при отказе (400/403/404): статус snapshot-а не изменён.

Acceptance Criteria

  • Given Active-snapshot, When news вызывает detach с Reason = BlockReplaced, Then 200, Status = Detached, заполнены DetachedAt/DetachReason; зафиксирован SelectionSnapshotDetachedEvent.
  • Given только что отвязанный snapshot, When news повторяет detach (тот же или другой Reason), Then 200 без изменений — DetachReason первичной отвязки не перезаписывается (INV-SNP-005), повторного события нет.
  • Given SnapshotId, которого нет, When detach, Then 404 SELECTIONS_SNAPSHOT_NOT_FOUND.
  • Given Reason вне enum, When detach, Then 400 VALIDATION_ERROR.
  • Given вызыватель не news, When detach, Then 403 FORBIDDEN (INV-SNP-003).
  • Given одновременные «замена блока» (BlockReplaced) и «удаление поста» (PostDeleted) на один snapshot, When обе доходят, Then первый переход фиксируется, второй — идемпотентный no-op (побеждает первый по оптимистической блокировке; причина первичной отвязки сохраняется).
  • Given отвязанный snapshot, When news рендерит старую версию поста, Then Content по-прежнему доступен через get-selection-snapshot.md (INV-SNP-006).

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

  • Добавление значения в SnapshotDetachReason — additive (неизвестное значение отклоняет валидатор до обновления сервиса — деплой selections раньше news).
  • Logs: SnapshotId, Reason, RequestedByUserId, был ли no-op; metrics: selections_snapshot_detached_total{reason,idempotent_hit}; traces: сквозной span из news. Runbook: «пост показывает не тот состав» — сверка SnapshotId блока ↔ статусов snapshot-ов (../README.md).

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

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

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