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 area | authoring |
| Feature group | authoring |
| Статус | draft |
| Документ | docs/06-services/news/api/authoring/publish-post.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (аутентифицированный автор/менеджер компании; guest — 401) |
| Auth policy / Permissions | JWT 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 isolation | TenantId = AuthorCompanyId (nullable, ADR-0052 п.4); чтение опубликованного — платформенный уровень |
| Audit | update-переход публикации + append-only PostVersion (CreatedByUserId = actor.UserId); PII нет |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/posts/{id}/publish |
| Success status | 200 |
| Idempotency header | не применяется — каждая публикация намеренно пишет новый PostVersion; защита от гонки — оптимистическая блокировка версии поста |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Единая секция: HTTP-источник + типизированное поле request-модели PublishPostCommand.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
PostId | route (id) | Guid | Да | != Guid.Empty; существующий неудалённый Post (шаг 2) | — |
ActorUserId | identity claims | Guid | Да | из JWT актора | стамп актора, не из тела; record-level |
Тело запроса отсутствует — публикуется текущее состояние черновика/поста по id.
Модель ответа PublishResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
PostId | Guid | Да | Post.Id | — | Опубликованный пост. |
Status | PostStatus (enum-string) | Да | Post.Status (= Published) | — | Итоговый статус; всегда Published. |
VersionNumber | int | Да | Post.CurrentVersionNumber (после инкремента) | — | Номер зафиксированной версии (PostVersion.VersionNumber). |
PublishedAt | DateTimeOffset | Да | Post.PublishedAt | — | Момент текущей публикации. |
Алгоритм
- Контекст и доступ. Публикует автор поста или менеджер его компании (
User); гость →401. Требуется правоnews.posts.publish. Идемпотентность не применяется: каждая публикация намеренно фиксирует новыйPostVersion, гонку снимает оптимистическая блокировка версии. - Проверка входа и доступа.
PostId != Guid.Empty, иначеVALIDATION_FAILED. Найти пост (вместе с контентом); если не найден илиArchived→NEWS_POST_NOT_FOUND. Проверить record-level (INV-NWS-007): актор — автор, либо менеджер компании в scopeAuthorCompanyId, либо носительnews.posts.manage; иначе →403. Допустимость состояния:Status ∈ {Draft, Unpublished, Published}(все неудалённые допускают публикацию/republish;Archivedуже исключён). - Что читаем и проверяем. Загрузить категорию поста (нужна для 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).
- eligibility (INV-NWS-002):
- Публикация и слепок. Перевести пост
{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инкрементируется. - Согласованность и событие. Пост (переход), новая строка
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. - Результат.
PublishResult { PostId, Status = Published, VersionNumber, PublishedAt }→200. Пост попадает в ленту (частичный индексix_posts_feed where status='Published') и в поисковую проекциюnews-posts.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | событие post-published → pin.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_FAILED | 400 | пустой PostId | «Некорректный идентификатор поста» | no retry |
NEWS_POST_NOT_FOUND | 404 | пост не найден / Archived | «Пост не найден» | no retry |
NEWS_POST_EMPTY | 422 | пустой Title ∨ Blocks.Count == 0 (INV-NWS-004) | «Заполните заголовок и добавьте хотя бы один блок» | no retry |
NEWS_SNAPSHOT_INVALID | 422 | SelectionEmbed-блок ссылается на не-Active/отсутствующий snapshot (INV-NWS-004) | «Встроенная подборка недоступна — обновите блок» | no retry |
NEWS_CATEGORY_NOT_ALLOWED | 403 | AuthorType ∉ 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_UNAVAILABLE | 503 | selections недоступен на шаге 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 — секунды:
post-published→pin.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; dashboardnews-overview(publish rate, ошибкиselections-клиента, outbox-lag, DLQ); runbook — «застрявший outbox», «пост показывает не тот состав» (сверкаSnapshotId).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): POST /api/v1/posts/{id}/unpublish — снять пост с публикации
- Use Cases (4): UC-NWS-001. Создать черновик и опубликовать пост, UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot), UC-NWS-006. История версий поста (полный snapshot), UC-NWS-007. Категории по типам авторов (eligibility публикации)
- События (1): Event Contract: pin.news.post-published (PostPublishedKafkaEvent)
- Дашборды (1): Панель Наблюдаемости: news-overview (эксплуатация)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности:
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.