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

POST /api/v1/posts/{id}/publish — публикация поста (snapshot + событие)

Назначение

Перевести пост в Published (первичная публикация Draft → Published либо повторная Unpublished/Published → Published), в одном согласованном изменении зафиксировать иммутабельный полный слепок PostVersion (INV-NWS-005) и опубликовать интеграционное событие pin.news.post-published.v1 (уведомления подписчиков, таймлайн автора).

Metadata

ПолеЗначение
Сервис/контекстnews
API areaauthoring
Feature groupauthoring
Статусdraft
Документdocs/06-services/news/api/authoring/publish-post.md

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

ПроверкаЗначение
Actor typeUser (аутентифицированный автор/менеджер компании; guest — 401)
Auth policy / PermissionsJWT identity; publish-eligibility — permission news.posts.publish (INV-NWS-002); каталог — identity/domain/permission.md
Scope (RBAC)self (AuthorUserId) + company (AuthorCompanyId) для менеджеров застройщика/агентства
Record-levelмутация только AuthorUserId, либо менеджер компании в scope AuthorCompanyId, либо permission news.posts.manage (INV-NWS-007); иначе 403
Tenant isolationTenantId = AuthorCompanyId (nullable, ADR-0052 п.4); чтение опубликованного — платформенный уровень
Auditupdate-переход публикации + append-only PostVersion (CreatedByUserId = actor.UserId); PII нет

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/posts/{id}/publish
Success status200
Idempotency headerне применяется — каждая публикация намеренно пишет новый PostVersion; защита от гонки — оптимистическая блокировка версии поста
Correlationсквозной trace через traceparent

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

Единая секция: HTTP-источник + типизированное поле request-модели PublishPostCommand.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PostIdroute (id)GuidДа!= Guid.Empty; существующий неудалённый Post (шаг 2)
ActorUserIdidentity claimsGuidДаиз JWT акторастамп актора, не из тела; record-level

Тело запроса отсутствует — публикуется текущее состояние черновика/поста по id.

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

ПолеТипОбязательностьИсточникMaskingОписание
PostIdGuidДаPost.IdОпубликованный пост.
StatusPostStatus (enum-string)ДаPost.Status (= Published)Итоговый статус; всегда Published.
VersionNumberintДаPost.CurrentVersionNumber (после инкремента)Номер зафиксированной версии (PostVersion.VersionNumber).
PublishedAtDateTimeOffsetДаPost.PublishedAtМомент текущей публикации.

Алгоритм

  1. Контекст и доступ. Публикует автор поста или менеджер его компании (User); гость → 401. Требуется право news.posts.publish. Идемпотентность не применяется: каждая публикация намеренно фиксирует новый PostVersion, гонку снимает оптимистическая блокировка версии.
  2. Проверка входа и доступа. PostId != Guid.Empty, иначе VALIDATION_FAILED. Найти пост (вместе с контентом); если не найден или ArchivedNEWS_POST_NOT_FOUND. Проверить record-level (INV-NWS-007): актор — автор, либо менеджер компании в scope AuthorCompanyId, либо носитель news.posts.manage; иначе → 403. Допустимость состояния: Status ∈ {Draft, Unpublished, Published} (все неудалённые допускают публикацию/republish; Archived уже исключён).
  3. Что читаем и проверяем. Загрузить категорию поста (нужна для eligibility и CategorySlug слепка). Проверки готовности к публикации:
    • eligibility (INV-NWS-002): AuthorType входит в AllowedAuthorTypes категории и есть право news.posts.publish, иначе → NEWS_CATEGORY_NOT_ALLOWED;
    • непустой контент (INV-NWS-004): Title непуст после trim и есть хотя бы один блок, иначе → NEWS_POST_EMPTY;
    • валидность встроенных подборок (INV-NWS-004): для каждого SelectionEmbed-блока статус snapshot в selections = Active; не-Active/отсутствующий → NEWS_SNAPSHOT_INVALID. Если selections недоступен → NEWS_SELECTIONS_UNAVAILABLE (503, retry).
  4. Публикация и слепок. Перевести пост {Draft|Unpublished|Published} → Published. Reason = Published при первой публикации (Draft → Published), иначе Republished. PublishedAt = now; FirstPublishedAt = now только при первой публикации (не меняется при republish). Зафиксировать иммутабельный слепок PostSnapshotDocument (полная копия Name/Suffix/Title/Subtitle/CategoryId/ CategorySlug/AuthorUserId/AuthorType/AuthorCompanyId/ProjectRefs/CoverFileId/CommentsEnabled/ Content/CapturedAt = now) как новую версию PostVersion (VersionNumber = CurrentVersionNumber + 1, Reason, CreatedByUserId = актор; INV-NWS-005 / INV-PV-002 / INV-PV-003); CurrentVersionNumber инкрементируется.
  5. Согласованность и событие. Пост (переход), новая строка PostVersion и публикуемое интеграционное событие фиксируются атомарно (INV-NWS-005 / INV-PV-003; конфликт номера версии страхует уникальность пары (PostId, VersionNumber), гонку перехода — оптимистическая блокировка). Публикуется событие post-published (топик pin.news.post-published.v1, ContractVersion = 1, ключ партиционирования PostId) с payload { PostId, AuthorUserId, AuthorType, AuthorCompanyId, CategoryId, Suffix, Title, Subtitle, CoverFileId, ProjectIds, VersionNumber, PublishedAt, FirstPublishedAt } — потребители: уведомления подписчиков, таймлайн автора. Дом контракта — post-published.md.
  6. Результат. PublishResult { PostId, Status = Published, VersionNumber, PublishedAt }200. Пост попадает в ленту (частичный индекс ix_posts_feed where status='Published') и в поисковую проекцию news-posts.

Диаграмма

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

ТипДетали
Integration eventsсобытие post-publishedpin.news.post-published.v1 (key = PostId, ContractVersion = 1)
External callsпроверка статуса встроенных подборок в selections (m2m, timeout 2 c, retry 1) — только при наличии SelectionEmbed-блоков
Cache / projections / searchпост попадает в ленту (частичный ix_posts_feed where status='Published') и в Elasticsearch-проекцию news-posts — search-индексатор upsert-ит документ по PostVersionCapturedEvent (INV-NWS-008, search/posts.md)
Notificationsопосредованно: notifications — консюмер post-published (подписчики автора); user-activity — таймлайн действий автора (опц., ADR-0054)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400пустой PostId«Некорректный идентификатор поста»no retry
NEWS_POST_NOT_FOUND404пост не найден / Archived«Пост не найден»no retry
NEWS_POST_EMPTY422пустой TitleBlocks.Count == 0 (INV-NWS-004)«Заполните заголовок и добавьте хотя бы один блок»no retry
NEWS_SNAPSHOT_INVALID422SelectionEmbed-блок ссылается на не-Active/отсутствующий snapshot (INV-NWS-004)«Встроенная подборка недоступна — обновите блок»no retry
NEWS_CATEGORY_NOT_ALLOWED403AuthorType ∉ Category.AllowedAuthorTypes ∨ нет news.posts.publish (INV-NWS-002)«Публикация в этой категории недоступна»no retry
403 (record-level)403не автор / не менеджер scope / нет news.posts.manage (INV-NWS-007)«Недостаточно прав на публикацию поста»no retry
NEWS_SELECTIONS_UNAVAILABLE503selections недоступен на шаге 3«Сервис подборок временно недоступен»retry

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

Предусловия (истинны до сценария):

  • актор — автор поста, менеджер компании в scope AuthorCompanyId либо носитель news.posts.manage (INV-NWS-007), с правом news.posts.publish;
  • пост существует, неудалён, в статусе Draft/Unpublished/Published;
  • готовность к публикации: AuthorType ∈ Category.AllowedAuthorTypes (INV-NWS-002), непустой Title и хотя бы один блок (INV-NWS-004), все встроенные подборки — Active-snapshot в selections;
  • selections доступен, если у поста есть SelectionEmbed-блоки.

Постусловия (гарантированы после успешной публикации):

  • пост в Published, PublishedAt = now, FirstPublishedAt выставлен при первой публикации и неизменен при republish; CurrentVersionNumber инкрементирован;
  • в том же согласованном изменении добавлена иммутабельная строка PostVersion (полный слепок PostSnapshotDocument, Reason = Published/Republished, VersionNumber уникален в паре (PostId, VersionNumber) — INV-NWS-005 / INV-PV-002/003);
  • атомарно зафиксировано исходящее интеграционное событие post-published;
  • пост попал в ленту (частичный индекс ix_posts_feed where status='Published') и в поисковую проекцию news-posts (upsert по слепку).

Интеграционная последовательность (после коммита)

Синхронно в запросе: проверки доступа/eligibility/непустоты → синхронная проверка статусов встроенных подборок в selections (Active?) → переход → Published + слепок PostVersion + фиксация исходящего события → 200 клиенту (пост уже в ленте, source of truth — news). Далее — асинхронно, событие доставляется гарантированно (at-least-once), окно eventual-consistency — секунды:

  1. post-publishedpin.news.post-published.v1: notifications — уведомление подписчиков автора (лента subscriptions); user-activity — таймлайн действий автора (опц., ADR-0054); search-индексатор — upsert документа в проекцию news-posts (по PostVersionCapturedEvent, INV-NWS-008).

До доставки автор уже видит пост в статусе Published и в глобальной ленте; подписчики получают уведомление, а полнотекстовый поиск находит пост — с задержкой окна проекции.

Что предоставляет: post-published { PostId, AuthorUserId, AuthorType, AuthorCompanyId, CategoryId, Suffix, Title, Subtitle, CoverFileId, ProjectIds, VersionNumber, PublishedAt, FirstPublishedAt }, ContractVersion = 1, ключ партиционирования PostId (порядок публикаций одного поста сохранён) — дом контракта post-published.md. Что ожидает от selections (синхронно, только при наличии embed-блоков): статус каждого встроенного snapshot = Active; не-Active/отсутствующий → NEWS_SNAPSHOT_INVALID; недоступность → NEWS_SELECTIONS_UNAVAILABLE (503, retry). Конкурентность/компенсации: гонку двух публикаций снимает уникальность пары (PostId, VersionNumber) + оптимистическая блокировка версии поста; компенсаций/саги нет — переход, PostVersion и событие фиксируются атомарно на одном агрегате.

Acceptance Criteria

  • Первичная публикация. Дано: автор, пост Draft с непустым Title и ≥1 блоком, категория eligible, embed-подборки Active. Когда: POST /api/v1/posts/{id}/publish. Тогда: 200, Status = Published, Reason = Published, FirstPublishedAt = PublishedAt, VersionNumber = 1, опубликовано post-published, пост в ленте и поиске.
  • Повторная публикация (republish). Дано: пост Published/Unpublished, автор внёс правки. Когда: POST publish. Тогда: 200, новый PostVersion (Reason = Republished, VersionNumber++), FirstPublishedAt неизменен, событие post-published снова опубликовано.
  • Пустой контент. Дано: пустой Title или ноль блоков (INV-NWS-004). Когда: POST publish. Тогда: 422 NEWS_POST_EMPTY, статус не меняется, PostVersion не пишется.
  • Невалидная встроенная подборка. Дано: SelectionEmbed-блок ссылается на не-Active/отсутствующий snapshot (INV-NWS-004). Когда: POST publish. Тогда: 422 NEWS_SNAPSHOT_INVALID.
  • Категория недоступна автору. Дано: AuthorType ∉ Category.AllowedAuthorTypes или нет news.posts.publish (INV-NWS-002). Когда: POST publish. Тогда: 403 NEWS_CATEGORY_NOT_ALLOWED.
  • selections недоступен. Дано: таймаут/5xx при проверке статусов подборок. Когда: POST publish. Тогда: 503 NEWS_SELECTIONS_UNAVAILABLE, retry; PostVersion и событие не фиксируются.
  • Архивный/несуществующий пост. Дано: пост Archived или отсутствует. Когда: POST publish. Тогда: 404 NEWS_POST_NOT_FOUND.
  • Не владелец. Дано: актор не автор/не менеджер scope и без news.posts.manage (INV-NWS-007). Когда: POST publish. Тогда: 403.
  • Конкурентная публикация. Дано: две параллельные публикации одного поста. Когда: обе POST publish. Тогда: одна фиксирует VersionNumber, вторая упирается в уникальность пары/блокировку версии и повторяется; двойного PostVersion с одним номером нет.
  • Гость. Дано: запрос без валидного JWT. Когда: POST publish. Тогда: 401.

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

  • Новые optional-поля PublishResult/payload события — additive; удаление/переименование/смена типа — breaking (migration + rollout). PostSnapshotDocument иммутабелен и не мигрируется — additive-only в рамках ContractVersion = 1 (post-version.md).
  • Logs: post publish requested/published (PostId, VersionNumber, Reason, ActorUserId); metrics: news_posts_published_total{reason,result}, latency p95; traces — сквозной trace через traceparent; dashboard news-overview (publish rate, ошибки selections-клиента, outbox-lag, DLQ); runbook — «застрявший outbox», «пост показывает не тот состав» (сверка SnapshotId).

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

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

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

  • Сущности: post.md (агрегат, INV-NWS-002/004/005/007, статусная машина, событие post-published), post-version.md (слепок, INV-PV-002/003, PostSnapshotDocument), post-block.md (Content, SelectionEmbedBlock).
  • Событие: post-published.md (контракт pin.news.post-published.v1).
  • Соседи по area: POST /api/v1/posts/{id}/unpublish, PUT /api/v1/posts/{id}/blocks, GET /api/v1/posts/{id}/versions (список версий) — карта в README.
  • Snapshot подборки: selection-snapshot.md (INV-SEL-001/SNP-006).
  • ADR: ADR-0052, ADR-0054, ADR-0056.