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

GET /api/v1/posts/{id}/versions/{versionNumber} — Конкретный snapshot версии поста

Назначение

Authoring-чтение одной иммутабельной версии поста: автор (или менеджер компании-автора) открывает конкретный snapshot из истории публикаций (PostVersion, полное версионирование — не diff) на странице «История версий → версия N». Метод отдаёт полный слепок PostSnapshotDocument (метаданные + подпись автора + ProjectRefs + типизированные блоки, включая SelectionEmbed.SnapshotId) ровно в том виде, в каком он был заморожен при публикации. Состав подборок читается по SnapshotId из selections даже для Detached-подборок (snapshot-ы иммутабельны и не удаляются, INV-SNP-006), а display-данные объектов недвижимости embed-блока не заморожены — рендер добирает актуальные из catalog по PropertyObjectId (решение №3, README). Мутаций нет. Список версий — соседний endpoint list-post-versions.md. Дом полей слепка — post-version.md.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (автор поста либо менеджер компании-автора); JWT обязателен — гость/аноним → 401
Auth policy / Permissionsnews.posts.manage-own (семейство news.posts.manage); каталог — permission.md
Scope (RBAC)проект/организация: self (AuthorUserId) + company scope менеджера (AuthorCompanyId)
Record-levelPost.AuthorUserId == actor.UserId или actor — менеджер Post.AuthorCompanyId со scope; иначе FORBIDDEN (INV-NWS-007, post-version.md §Permissions)
Tenant isolationTenantId = Post.AuthorCompanyId для постов застройщика/агентства (ADR-0052); посты инвестора — вне tenant (record-level по AuthorUserId)
Auditnone (read; сам PostVersion — append-only аудит публикаций)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/posts/{id}/versions/{versionNumber}
Success status200
Idempotency headerне применяется (safe GET)
Correlationсквозной trace через traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PostIdroute (id)GuidДа!= Guid.Emptytrusted route-параметр
VersionNumberroute (versionNumber)intДа>= 1 (монотонный доменный номер, INV-PV-002)trusted route-параметр
ActorUserIdidentity claimsGuidДавалидный JWT (иначе 401); резолв record-level (шаг 2)

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

ПолеТипОбязательностьИсточникMaskingОписание
PostIdGuidДаPostVersion.PostIdПост, чью публикацию фиксирует слепок.
VersionNumberintДаPostVersion.VersionNumberНомер версии в пределах поста.
ReasonPostVersionReason (enum-string)ДаPostVersion.ReasonPublished/Republished — причина захвата.
CreatedByUserIdGuidДаPostVersion.CreatedByUserIdКто опубликовал/переопубликовал (актор перехода; может отличаться от автора поста).
CapturedAtDateTimeOffsetДаSnapshot.CapturedAt (= PostVersion.CreatedAt)Момент снятия слепка.
SnapshotPostSnapshotModelДаPostVersion.Snapshot (jsonb)Полный замороженный слепок поста (ниже).

PostSnapshotModel (проекция PostSnapshotDocument, дом — post-version.md)

ПолеТипОбязательностьИсточник (frozen, ContractVersion=1)Описание
NamestringДаSnapshot.Name«Название» поста на момент публикации.
SuffixstringДаSnapshot.SuffixURL-slug на момент публикации.
TitlestringДаSnapshot.TitleЗаголовок.
Subtitlestring?НетSnapshot.SubtitleПодзаголовок.
CategoryIdGuidДаSnapshot.CategoryIdКатегория (frozen).
CategorySlugstringДаSnapshot.CategorySlugKebab-slug категории на момент публикации (мог быть переименован позже — заморожен).
AuthorPostVersionAuthorModelДаfrozen refs + живая подписьАвтор слепка (ниже).
ProjectIdsIReadOnlyList<Guid>Да (допустим пустой)Snapshot.ProjectRefsПривязка застройщика к проектам (карточки добирает фронт в catalog).
CoverFileIdGuid?НетSnapshot.CoverFileIdОбложка (storage/CDN, без FK).
CommentsEnabledboolДаSnapshot.CommentsEnabledФлаг комментариев на момент публикации.
BlocksIReadOnlyList<PostBlockModel>ДаSnapshot.Content.Blocks (по SortOrder)Типизированные блоки слепка (ниже).

PostVersionAuthorModel

Идентичность автора — frozen из слепка; подпись (DisplayName/аватар/лого) — живая из реплик identity (может отличаться от подписи на момент публикации; для истории автор смотрит свой пост).

ПолеТипОбязательностьИсточникОписание
AuthorUserIdGuidДаSnapshot.AuthorUserId (frozen)Автор поста.
TypePostAuthorType (enum-string)ДаSnapshot.AuthorType (frozen)Investor/DeveloperManager/Agent/AgencyManager/PinEmployee — дом post.md.
AvatarFileIdGuid?НетAuthorReplica.AvatarFileId (live)Аватар (storage/CDN).
DisplayNamestringДаAuthorReplica.DisplayName (live)Подпись автора; отсутствие реплики (лаг) → мягкая деградация.
AuthorCompanyPostVersionAuthorCompanyModel?Нетfrozen id + живая подписьКомпания-автор (nested ref-model); null — авторство не от компании.

PostVersionAuthorCompanyModel (nested ref-model компании-автора)

ПолеТипОбязательностьИсточникОписание
AuthorCompanyIdGuidДаSnapshot.AuthorCompanyId (frozen)Идентификатор компании-автора (заморожен в слепке).
AuthorCompanyNamestringДаCompanyReplica.Name (live)Название компании-автора (живое из реплики).

PostBlockModel (полиморфная, дискриминатор Kind)

База: BlockId (Guid), Kind (PostBlockKind enum-string: Text/ImageGallery/SelectionEmbed), SortOrder (int). Sealed-подтипы — проекция typed-графа PostContentDocument, домpost-block.md; ниже — только дельта выдачи:

  • TextBlockModel (Kind=Text): Format (TextFormat enum-string Markdown/PlainText), Text (string; санитизация — при рендере). Производный PlainText — только источник поиска, в ответ не входит.
  • ImageGalleryBlockModel (Kind=ImageGallery): Layout (GalleryLayout enum-string Single/Grid/Carousel), Images (IReadOnlyList<GalleryImageModel>FileId Guid, SortOrder int, Caption string?, Alt string?, Width int?, Height int?).
  • SelectionEmbedBlockModel (Kind=SelectionEmbed): SnapshotId (Guid, frozen), SourceSelectionId (Guid), SourceSelectionPublicCode (string, 12 base62), Title (string), ItemsCount (int). Backend отдаёт только ссылки; состав фронт читает по SnapshotId в selections (доступен даже если подборка Detached — INV-SNP-006), display-данные объектов недвижимости — актуальные из catalog по PropertyObjectId (не заморожены, решение №3).

Алгоритм

  1. Контекст и доступ. Читает автор поста либо менеджер компании-автора; требуется валидный JWT (гость/невалидный токен → 401) и право news.posts.manage-own.
  2. Проверка входа и доступа. PostId != Guid.Empty, VersionNumber >= 1, иначе ValidationError (400). Найти пост по PostId; если не найден → NEWS_POST_NOT_FOUND (404). Статус поста не фильтруется: историю читают и для Unpublished/Archived (версии живут, пока жив пост). Проверить record-level (INV-NWS-007): актор — автор (AuthorUserId) либо менеджер компании AuthorCompanyId в scope; иначе → FORBIDDEN (403). 404 и 403 различимы намеренно.
  3. Что читаем. Найти версию по паре (PostId, VersionNumber) — уникальна (INV-PV-002); если её нет → NEWS_VERSION_NOT_FOUND (404). Полный слепок PostSnapshotDocument уже хранится в строке версии. Для живой подписи автора — реплика по AuthorUserId слепка; при наличии компании-автора — реплика компании. Синхронных вызовов selections/catalog нет: состав встроенных подборок и объекты недвижимости резолвит фронт по SnapshotId/PropertyObjectId.
  4. Сборка ответа. Переходов состояний нет — слот версии иммутабелен (INV-PV-001). Собрать PostVersionDetail по таблицам моделей: метаданные, CategorySlug, ProjectIds; идентичность автора — из замороженного слепка (frozen), подпись (DisplayName/аватар/лого) — из живых реплик identity; блоки по SortOrder; SelectionEmbed-блоки отдают только snapshot-ссылки. Неизвестный Kind блока при чтении → ошибка (INV-NWS-B06, строгая типизация).
  5. Результат. PostVersionDetail200. Поля слепка строго frozen (иммутабельны); подпись автора и display-данные объектов недвижимости встроенных блоков — актуальные/eventual, не заморожены.

Диаграмма

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

ТипДетали
Integration eventsnone (read-only)
External callsnone — selections (GET snapshot по SnapshotId, доступен и для Detached) и catalog (объекты недвижимости по PropertyObjectId) вызывает фронт при рендере, не бэкенд
Cache / projections / searchчтение по PK поста + unique ux_post_versions_number; кэша нет (ADR-0056)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401JWT отсутствует/невалиденбез тела схемы (фронт очищает сессию)re-login
ValidationError400PostId == Guid.Empty или VersionNumber < 1Список ошибок полейno retry
NEWS_POST_NOT_FOUND404Поста нет«Пост не найден»no retry
NEWS_VERSION_NOT_FOUND404Пост есть, но нет версии с таким VersionNumber«Версия поста не найдена»no retry
FORBIDDEN403Пост есть, но actor — не автор и не менеджер компании-автора в scope«Недостаточно прав»no retry

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

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

  • актор аутентифицирован; он — автор поста либо менеджер компании-автора в scope AuthorCompanyId (INV-NWS-007);
  • пост существует (в любом статусе); запрошенная версия (PostId, VersionNumber) существует и иммутабельна (INV-PV-001).

Постусловия (гарантированы после чтения):

  • состояние не изменяется (read-only): нет мутаций, событий и синхронных вызовов бэкенда в selections/catalog;
  • отдан полный замороженный слепок PostSnapshotDocument ровно в виде на момент публикации: метаданные, CategorySlug, ProjectIds, идентичность автора и типизированные блоки, включая SelectionEmbed.SnapshotIdfrozen (иммутабельны);
  • не заморожены и берутся живыми: подпись автора/компании (реплики identity; при лаге → мягкая деградация) и display-данные объектов недвижимости embed-блоков (добирает фронт из catalog по PropertyObjectId, решение №3).

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

Бэкенд синхронно отдаёт слепок из своей БД (никаких синхронных вызовов в selections/catalog). Что предоставляет наружу: ссылки embed-блока (SnapshotId, SourceSelectionId, PublicCode, ItemsCount), по которым фронт дорезолвит: состав подборки — из selections по SnapshotId (доступен даже для Detached-подборок, snapshot иммутабелен INV-SNP-006); display-данные объектов недвижимости — из catalog по PropertyObjectId (актуальные, не заморожены). Что ожидает: локальная реплика автора/компании для живой подписи; её отсутствие (лаг консюмера identity) не блокирует ответ — деградация подписи.

Acceptance Criteria

  • Чтение конкретной версии. Дано: автор, версия (PostId, N) существует. Когда: GET /api/v1/posts/{id}/versions/{N}. Тогда: 200, полный frozen-слепок с блоками по SortOrder, SelectionEmbed-блоки отдают только ссылки.
  • Живая подпись поверх frozen-идентичности. Дано: DisplayName/лого автора изменились после публикации. Когда: GET. Тогда: идентичность (AuthorUserId/AuthorType/AuthorCompanyId) — из слепка, подпись (DisplayName/аватар/название компании) — текущая из реплик.
  • История доступна для снятого/архивного поста. Дано: пост Unpublished/Archived. Когда: GET. Тогда: 200 (версии живут, пока жив пост).
  • Embed на Detached-подборке. Дано: подборка блока позже отвязана. Когда: фронт читает selections по SnapshotId. Тогда: состав доступен (INV-SNP-006).
  • Лаг реплики. Дано: реплика автора не догнала. Когда: GET. Тогда: DisplayName мягко деградирует, ответ отдаётся.
  • Несуществующий пост. Дано: поста нет. Когда: GET. Тогда: 404 NEWS_POST_NOT_FOUND.
  • Несуществующая версия. Дано: пост есть, версии с VersionNumber нет. Когда: GET. Тогда: 404 NEWS_VERSION_NOT_FOUND.
  • Не владелец. Дано: актор не автор/не менеджер компании-автора в scope. Когда: GET. Тогда: 403 FORBIDDEN (различимо от 404).
  • Невалидный вход. Дано: PostId == Guid.Empty или VersionNumber < 1. Когда: GET. Тогда: 400 ValidationError.
  • Гость. Дано: нет/невалидный JWT. Когда: GET. Тогда: 401.

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

  • Новые optional-поля PostVersionDetail/PostSnapshotModel/блоков — additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Slot читается по всем ContractVersion из БД — snapshot-ы не мигрируются (иммутабельны, post-version.md); неизвестный Kind блока → fail (INV-NWS-B06, post-block.md).
  • Logs: PostId, VersionNumber, actor.UserId, способ доступа (self/manager), результат; metrics — news_post_version_read_requests_total{result}, latency histogram (SLO чтения p99 < 200 мс — README); traces — сквозной trace через traceparent; dashboard news-overview.

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

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

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