GET /api/v1/posts/{idOrSuffix} — Чтение опубликованного поста
Назначение
Публичная карточка поста ленты новостей для страницы /news/{suffix}: отдаёт метаданные, подпись
автора/компании, типизированные блоки контента и денормализованные счётчики одной выборкой без синхронных
обращений в чужие контексты (подписи — из реплик identity, состав блоков — из контента поста). Метод
читает только опубликованный пост (Status = Published); черновики и снятые с публикации доступны автору
через authoring-области, а здесь неотличимы от несуществующих (404). Рендер объектов недвижимости в
embed-подборках выполняет фронт (актуальный snapshot из selections + карточки из catalog), трекинг
просмотра пишет фронт → user-activity (ADR-0054). Дом полей — ../../../domain/post.md.
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | posts |
| Статус | draft |
| Документ | docs/06-services/news/api/public/posts/get-post.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous (гость) или User (авторизованный) — чтение опубликованного контента публично |
| Auth policy / Permissions | none (опубликованный пост — платформенный уровень); валидный JWT опционален, невалидный → 401 |
| Scope (RBAC) | платформа |
| Record-level | не применяется (публичное чтение; authoring/record-level — в authoring-областях, INV-NWS-007) |
| Tenant isolation | не применяется — опубликованный контент читается вне tenant (ADR-0052 п.3) |
| Audit | none; факт просмотра фиксирует фронт событием в user-activity (ADR-0054), не этот метод |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/posts/{idOrSuffix} |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
IdOrSuffix | route | string | Да | непустой после trim, 1..128; либо валидный Guid (резолв по Id), либо kebab-slug ^[a-z0-9]+(-[a-z0-9]+)*$ (резолв по Suffix) — INV-NWS-001 | trusted route-параметр |
ActorClass | identity middleware | PostReaderActorClass (enum-string) | Да | Anonymous/User; невалидный JWT → 401 (гость без токена допустим) | классы — README |
Tenant/permission не требуются.
Модель ответа PostDetail
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
PostId | Guid | Да | Post.Id | — | Идентификатор поста. |
Name | string | Да | Post.Name | — | «Название» поста. |
Suffix | string | Да | Post.Suffix | — | URL-slug, база для /news/{suffix}. |
Title | string | Да | Post.Title | — | Заголовок. |
Subtitle | string? | Нет | Post.Subtitle | — | Подзаголовок. |
CategorySlug | string | Да | PostCategory.Slug | — | Kebab-код категории (фильтр/ссылка ленты). |
CategoryTitle | string | Да | PostCategory.Title | — | Отображаемое название категории. |
Author | PostAuthorModel | Да | реплики (см. модель) | — | Подпись автора/компании. |
ProjectIds | IReadOnlyList<Guid> | Да (допустим пустой) | Post.ProjectRefs.ProjectIds | — | Привязка застройщика к проектам; карточки добирает фронт в catalog. |
CoverFileId | Guid? | Нет | Post.CoverFileId | — | Обложка (storage/CDN, пресайн-ссылку строит рендер). |
Blocks | IReadOnlyList<PostBlockModel> | Да | Post.Content.Blocks (по SortOrder) | — | Типизированные блоки контента (см. модель). |
CommentsEnabled | bool | Да | Post.CommentsEnabled | — | Гейт комментирования (INV-NWS-006). |
CommentsCount | int | Да | Post.CommentsCount | — | Денормализованный счётчик комментариев. |
ViewsCount | long | Да | Post.ViewsCount | — | Денормализованные просмотры (трекинг — фронт→user-activity). |
PublishedAt | DateTimeOffset | Да | Post.PublishedAt | — | Момент текущей публикации. |
CurrentVersionNumber | int | Да | Post.CurrentVersionNumber | — | Номер последней версии (post-version). |
PostAuthorModel
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AuthorUserId | Guid | Да | Post.AuthorUserId | Автор (ссылка на страницу автора/подписку). |
DisplayName | string | Да | AuthorReplica.DisplayName | Публичная подпись (ФИО без отчества). |
Type | PostAuthorType (enum-string) | Да | AuthorReplica.Type (=Post.AuthorType) | Investor/DeveloperManager/Agent/AgencyManager/PinEmployee — дом post.md. |
AvatarFileId | Guid? | Нет | AuthorReplica.AvatarFileId | Аватар автора (storage/CDN). |
AuthorCompany | PostAuthorCompanyModel? | Нет | реплика (см. модель) | Компания-автор (nested ref-model); null — инвестор/агент/сотрудник вне компании. |
PostAuthorCompanyModel (nested ref-model компании-автора)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AuthorCompanyId | Guid | Да | Post.AuthorCompanyId | Идентификатор компании-автора (ссылка/фильтр ленты authorCompanyId). |
AuthorCompanyName | string | Да | CompanyReplica.Name | Название компании-автора. |
LogoFileId | Guid? | Нет | 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).
Алгоритм
- Контекст и доступ. Класс актора определяет middleware: валидный JWT →
User (невалидный/просроченный
→ 401), иначе Anonymous (без отказа — публичное чтение). Метод вне tenant.
- Проверка входа и резолв поста.
IdOrSuffix непуст, 1..128, соответствует Guid или
kebab-slug; иначе ValidationError (400). Пост находится: если параметр — Guid, по идентификатору;
иначе по уникальному Suffix среди неудалённых (INV-NWS-001). Если пост не найден или его статус не
Published (Draft/Unpublished/Archived) → NEWS_POST_NOT_FOUND (404): черновик/снятый неотличим
от несуществующего, редактирование доступно автору только через authoring-области.
- Что читаем. Блоки уже входят в контент поста (читаются вместе с ним). Дополнительно: категория поста
по её идентификатору (INV-NWS-002); подпись автора из реплики
AuthorReplica; при наличии
компании-автора — подпись из реплики CompanyReplica. Отсутствие реплики (лаг консюмера) → мягкая
деградация подписи, не 404.
- Правила выдачи и маппинг. Переходов состояний нет (чтение). Если компания-автор имеет статус
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).
- Результат.
PostDetail → HTTP 200. Консистентность подписей/счётчиков — eventual (лаг консюмеров
identity и reconcile-job); состав блоков — строго актуальный (контент поста). Счётчик просмотров этот
метод не инкрементирует — его пишет фронт → user-activity (ADR-0054).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Передан невалидный/просроченный JWT | без тела схемы (фронт очищает сессию) | re-login |
ValidationError | 400 | IdOrSuffix пуст / > 128 / не Guid и не kebab-slug | Список ошибок полей | no retry |
NEWS_POST_NOT_FOUND | 404 | Поста нет, 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).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы