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

GET /api/v1/posts/{idOrSuffix} — Чтение опубликованного поста

Назначение

Публичная карточка поста ленты новостей для страницы /news/{suffix}: отдаёт метаданные, подпись автора/компании, типизированные блоки контента и денормализованные счётчики одной выборкой без синхронных обращений в чужие контексты (подписи — из реплик identity, состав блоков — из контента поста). Метод читает только опубликованный пост (Status = Published); черновики и снятые с публикации доступны автору через authoring-области, а здесь неотличимы от несуществующих (404). Рендер объектов недвижимости в embed-подборках выполняет фронт (актуальный snapshot из selections + карточки из catalog), трекинг просмотра пишет фронт → user-activity (ADR-0054). Дом полей — ../../../domain/post.md.

Metadata

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

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

ПроверкаЗначение
Actor typeAnonymous (гость) или User (авторизованный) — чтение опубликованного контента публично
Auth policy / Permissionsnone (опубликованный пост — платформенный уровень); валидный JWT опционален, невалидный → 401
Scope (RBAC)платформа
Record-levelне применяется (публичное чтение; authoring/record-level — в authoring-областях, INV-NWS-007)
Tenant isolationне применяется — опубликованный контент читается вне tenant (ADR-0052 п.3)
Auditnone; факт просмотра фиксирует фронт событием в user-activity (ADR-0054), не этот метод

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
IdOrSuffixroutestringДанепустой после trim, 1..128; либо валидный Guid (резолв по Id), либо kebab-slug ^[a-z0-9]+(-[a-z0-9]+)*$ (резолв по Suffix) — INV-NWS-001trusted route-параметр
ActorClassidentity middlewarePostReaderActorClass (enum-string)ДаAnonymous/User; невалидный JWT → 401 (гость без токена допустим)классы — README

Tenant/permission не требуются.

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

ПолеТипОбязательностьИсточникMaskingОписание
PostIdGuidДаPost.IdИдентификатор поста.
NamestringДаPost.Name«Название» поста.
SuffixstringДаPost.SuffixURL-slug, база для /news/{suffix}.
TitlestringДаPost.TitleЗаголовок.
Subtitlestring?НетPost.SubtitleПодзаголовок.
CategorySlugstringДаPostCategory.SlugKebab-код категории (фильтр/ссылка ленты).
CategoryTitlestringДаPostCategory.TitleОтображаемое название категории.
AuthorPostAuthorModelДареплики (см. модель)Подпись автора/компании.
ProjectIdsIReadOnlyList<Guid>Да (допустим пустой)Post.ProjectRefs.ProjectIdsПривязка застройщика к проектам; карточки добирает фронт в catalog.
CoverFileIdGuid?НетPost.CoverFileIdОбложка (storage/CDN, пресайн-ссылку строит рендер).
BlocksIReadOnlyList<PostBlockModel>ДаPost.Content.Blocks (по SortOrder)Типизированные блоки контента (см. модель).
CommentsEnabledboolДаPost.CommentsEnabledГейт комментирования (INV-NWS-006).
CommentsCountintДаPost.CommentsCountДенормализованный счётчик комментариев.
ViewsCountlongДаPost.ViewsCountДенормализованные просмотры (трекинг — фронт→user-activity).
PublishedAtDateTimeOffsetДаPost.PublishedAtМомент текущей публикации.
CurrentVersionNumberintДаPost.CurrentVersionNumberНомер последней версии (post-version).

PostAuthorModel

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

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

ПолеТипОбязательностьИсточникОписание
AuthorCompanyIdGuidДаPost.AuthorCompanyIdИдентификатор компании-автора (ссылка/фильтр ленты authorCompanyId).
AuthorCompanyNamestringДаCompanyReplica.NameНазвание компании-автора.
LogoFileIdGuid?НетCompanyReplica.LogoFileIdЛого компании (storage/CDN).

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

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

  • TextBlockModel (Kind=Text): Format (TextFormat enum-string Markdown/PlainText), Text (string; санитизация — при рендере). Производный plain-text — только источник поиска, в ответ не входит.
  • 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), SourceSelectionId (Guid), SourceSelectionPublicCode (string, 12 base62), Title (string), ItemsCount (int). Display-данные объектов недвижимости не входят — фронт добирает актуальные (snapshot из selections + карточки из catalog по PropertyObjectId); цены не заморожены (решение №3).

Алгоритм

  1. Контекст и доступ. Класс актора определяет middleware: валидный JWT → User (невалидный/просроченный → 401), иначе Anonymous (без отказа — публичное чтение). Метод вне tenant.
  2. Проверка входа и резолв поста. IdOrSuffix непуст, 1..128, соответствует Guid или kebab-slug; иначе ValidationError (400). Пост находится: если параметр — Guid, по идентификатору; иначе по уникальному Suffix среди неудалённых (INV-NWS-001). Если пост не найден или его статус не Published (Draft/Unpublished/Archived) → NEWS_POST_NOT_FOUND (404): черновик/снятый неотличим от несуществующего, редактирование доступно автору только через authoring-области.
  3. Что читаем. Блоки уже входят в контент поста (читаются вместе с ним). Дополнительно: категория поста по её идентификатору (INV-NWS-002); подпись автора из реплики AuthorReplica; при наличии компании-автора — подпись из реплики CompanyReplica. Отсутствие реплики (лаг консюмера) → мягкая деградация подписи, не 404.
  4. Правила выдачи и маппинг. Переходов состояний нет (чтение). Если компания-автор имеет статус Suspended → пост исключается из публичной выдачи (INV-REP-003) → NEWS_POST_NOT_FOUND (404). Если автор Blocked → пост остаётся, подпись может быть помечена (INV-REP-002, поведение по умолчанию). Маппинг поле-в-поле — таблицы PostDetail/PostAuthorModel/PostBlockModel; SelectionEmbed-блоки отдают только snapshot-ссылки (SnapshotId, SourceSelectionId, SourceSelectionPublicCode, Title, ItemsCount) — синхронного вызова selections здесь нет; ViewsCount/CommentsCount — денормализованные (правятся reconcile-job).
  5. Результат. PostDetail → HTTP 200. Консистентность подписей/счётчиков — eventual (лаг консюмеров identity и reconcile-job); состав блоков — строго актуальный (контент поста). Счётчик просмотров этот метод не инкрементирует — его пишет фронт → user-activity (ADR-0054).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Передан невалидный/просроченный JWTбез тела схемы (фронт очищает сессию)re-login
ValidationError400IdOrSuffix пуст / > 128 / не Guid и не kebab-slugСписок ошибок полейno retry
NEWS_POST_NOT_FOUND404Поста нет, Status ∈ {Draft, Unpublished, Archived}, либо компания-автор Suspended«Пост не найден»no retry

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

  • Новые optional-поля PostDetail/блоков — additive (без breaking); удаление/переименование/смена типа или раскрытие ранее скрытого — breaking (migration + rollout). Неизвестный Kind блока при чтении → fail (строгая типизация, INV-NWS-B06 post-block.md).
  • Logs: idOrSuffix, способ резолва (by-id/by-suffix), PostId, класс актора, результат; metrics — news_post_read_requests_total{resolved_by,result}, latency histogram (SLO чтения поста p99 < 200 мс — README); traces/dashboard news-overview.

Acceptance Criteria

  • Чтение по suffix. Дано: Published-пост со Suffix. Когда: GET /api/v1/posts/{suffix}. Тогда: 200, PostDetail с блоками (по SortOrder), подписью автора/компании из реплик, CurrentVersionNumber; блоки SelectionEmbed отдают только ссылки на snapshot (SnapshotId/SourceSelectionId/…) без синхронного вызова selections.
  • Чтение по id. Дано: параметр — валидный Guid. Когда: GET. Тогда: резолв по Id; иначе резолв по Suffix среди неудалённых (INV-NWS-001).
  • Неопубликованный скрыт. Дано: пост Draft/Unpublished/Archived или не существует. Когда: GET. Тогда: 404 NEWS_POST_NOT_FOUND (неотличим от несуществующего; правка доступна автору через authoring-области).
  • Suspended-компания. Дано: компания-автор Suspended. Когда: GET. Тогда: 404 NEWS_POST_NOT_FOUND (INV-REP-003).
  • Blocked-автор. Дано: автор Blocked. Когда: GET. Тогда: пост отдаётся, подпись может быть помечена (INV-REP-002, поведение по умолчанию).
  • Некорректный параметр. Дано: IdOrSuffix пуст, > 128, либо не Guid и не kebab-slug. Когда: GET. Тогда: 400 ValidationError.
  • Просроченный токен. Дано: невалидный/просроченный JWT. Когда: GET. Тогда: 401 (аноним без токена — допустимо).
  • Лаг реплики. Дано: реплика подписи автора/компании отстала. Когда: GET. Тогда: подпись мягко деградирует (не 404); состав блоков — строго актуальный (контент поста).
  • Просмотр не инкрементируется. Дано: успешное чтение. Когда: GET. Тогда: ViewsCount этим методом не меняется — факт просмотра пишет фронт → user-activity (ADR-0054).

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

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

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