GET /api/v1/posts — Глобальная лента + поиск
Назначение
Единая платформенная (вне tenant) лента опубликованных постов новостей с курсорной пагинацией,
полнотекстовым поиском и набором фильтров. Вызывает анонимный гость или авторизованный пользователь;
получает страницу постов с уже готовыми подписями автора и компании-автора (из локальных реплик
identity, без синхронного обращения в чужие контексты). Без SearchTerm лента читается из основной
витрины постов; с SearchTerm — из поисковой проекции PostSearchDocument.
Ранжирование отложено (решение №10, INV-4): Sort = recommended прозрачно работает как date
(post-ranking-model.md).
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | feed |
| Статус | draft |
| Документ | docs/06-services/news/api/public/feed/list-global-feed.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous (гость) или User (JWT) |
| Auth policy / Permissions | none — публичное чтение опубликованного контента (news.posts.view — публичный); rate limit по IP |
| Scope (RBAC) | платформа (лента платформенного уровня, не организация/проект) |
| Record-level | не применяется — выдаются только Status = Published |
| Tenant isolation | не применяется — платформенная лента; опубликованный контент читается вне tenant (ADR-0052 п.3) |
| Audit | read-public — отдельная audit-запись не пишется |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/posts |
| Success status | 200 |
| Idempotency header | не применяется (безопасный идемпотентный GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
SearchTerm | query | string? | Нет | 1..256 после trim; если задан — полнотекстовый поиск по проекции PostSearchDocument (поле SearchText) | untrusted — только поисковый запрос, не raw-DSL/SQL |
CategoryId | query | Guid? | Нет | если задан — фильтр по категории поста (существование не проверяется — пустая выдача) | — |
AuthorType | query | PostAuthorType? | Нет | enum-строкой; неизвестное значение → VALIDATION_ERROR | — |
AuthorCompanyId | query | Guid? | Нет | фильтр по компании-автору | — |
ProjectId | query | Guid? | Нет | фильтр по привязке поста к проекту застройщика (ProjectRefs.ProjectIds) | — |
Sort | query | PostFeedSort | Нет | enum recommended/date/popularity, default recommended | — |
Cursor | query | string? | Нет | opaque base64 keyset-курсор; при повреждении → VALIDATION_ERROR | непрозрачный, не PII |
Limit | query | int | Нет | 1..30, default 20 | защита от тяжёлых страниц |
Enum PostFeedSort (query, enum-строкой)
| Значение | Порядок |
|---|
recommended | отложено → PublishedAt desc, Id desc (решение №10, INV-4); при включённом ранжировании → по убыванию ranking-score |
date | PublishedAt desc, Id desc |
popularity | ViewsCount desc, PublishedAt desc, Id desc |
PostAuthorType — дом значений post.md
(Investor/DeveloperManager/Agent/AgencyManager/PinEmployee).
Модель ответа — страница ленты (PostFeedItem)
Страница в keyset-режиме: список элементов + курсорные поля. Общее число (TotalCount) не вычисляется
(перф/SLO — пагинация курсорная).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Items | IReadOnlyList<PostFeedItem> | Да | projection | Страница постов в порядке сортировки. |
NextCursor | string? | Нет | computed (keyset) | Opaque-курсор следующей страницы; null — конец ленты. |
HasMore | bool | Да | computed | Есть ли ещё страницы. |
PostFeedItem
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
PostId | Guid | Да | Post.Id | — | Идентификатор поста. |
Suffix | string | Да | Post.Suffix | — | URL-slug (/news/{suffix}). |
Title | string | Да | Post.Title | — | Заголовок. |
Subtitle | string? | Нет | Post.Subtitle | — | Подзаголовок. |
CoverFileId | Guid? | Нет | Post.CoverFileId | — | Обложка (storage/CDN, без FK). |
CategorySlug | string | Да | PostCategory.Slug | — | Стабильный kebab-код категории. |
AuthorDisplayName | string | Да | AuthorReplica.DisplayName | — | Подпись автора (реплика identity). |
AuthorType | PostAuthorType | Да | Post.AuthorType | — | Тип автора (snapshot на момент создания). |
AuthorCompany | PostFeedAuthorCompany? | Нет | реплика (см. модель) | — | Компания-автор (nested ref-model); null — инвестор/сотрудник. |
PublishedAt | DateTimeOffset | Да | Post.PublishedAt | — | Момент текущей публикации. |
CommentsCount | int | Да | Post.CommentsCount | — | Денормализованный счётчик. |
ViewsCount | long | Да | Post.ViewsCount | — | Денормализованные просмотры (трекинг — user-activity). |
BlocksPreview | PostBlocksPreview | Да | computed из Post.Content | — | Превью содержимого (см. VO). |
HasSelectionEmbed | bool | Да | computed из Post.Content | — | Есть ли блок Kind = SelectionEmbed. |
PostFeedAuthorCompany (nested ref-model компании-автора)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AuthorCompanyId | Guid | Да | Post.AuthorCompanyId | Идентификатор компании-автора (ссылка/фильтр ленты authorCompanyId). |
AuthorCompanyName | string | Да | CompanyReplica.Name | Название компании-автора. |
LogoFileId | Guid? | Нет | CompanyReplica.LogoFileId | Лого компании (storage/CDN). |
PostBlocksPreview (typed VO ответа)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
FirstTextExcerpt | string? | Нет | первый текстовый блок, обрезка <= 280 | Затравка ленты; null — нет текстовых блоков. |
BlocksTotal | int | Да | Post.Content.Blocks.Count | Всего блоков в посте. |
Состав блоков — post-block.md; поля поста — post.md;
реплики подписей — identity-replicas.md. Внутренний AuthorUserId
наружу не отдаётся; компания — через ref-модель AuthorCompany.
Алгоритм
- Контекст и доступ. Актор — гость (
Anonymous) или авторизованный (User); JWT опционален и на
выдачу не влияет — лента платформенная, вне tenant. Отдельного permission нет: чтение опубликованного
контента публично.
- Проверка входа.
SearchTerm — 1..256 после trim; Limit — 1..30; Sort — допустимое значение
PostFeedSort; AuthorType — допустимое значение enum; Cursor (если задан) — корректный opaque-курсор
режима (keyset для ленты, search_after-токен для поиска). Любое нарушение → VALIDATION_ERROR.
Существование CategoryId/AuthorCompanyId/ProjectId не проверяется — несуществующий фильтр даёт
пустую страницу.
- Что читаем и маршрутизация.
- Без
SearchTerm (лента). Страница читается из витрины постов keyset-ом: базовый предикат
Status = Published + опциональные фильтры (AND) по CategoryId, AuthorType, AuthorCompanyId,
ProjectId (по привязке ProjectRefs.ProjectIds). Посты компаний со статусом Suspended
исключаются из выдачи (INV-REP-003).
- С
SearchTerm (поиск). Страница идентификаторов постов приходит из полнотекстовой проекции
PostSearchDocument (индекс news-posts): матч по SearchText; те же фильтры; условия
Status = Published и «компания-автор не Suspended» применяются до выполнения запроса (INV-REP-003).
- Автор со статусом
Blocked (INV-REP-002) по умолчанию из выдачи не исключается — подпись помечается
при отображении.
- Сортировка и пагинация. Порядок — по
Sort (таблица PostFeedSort): recommended (отложено) и
date → PublishedAt desc, Id desc; popularity → ViewsCount desc, PublishedAt desc, Id desc.
Пагинация курсорная: для ленты — keyset от Cursor (Id как детерминированный tiebreaker); для поиска —
search_after по тем же ключам сортировки. В обоих режимах читается на один элемент больше Limit,
чтобы определить наличие следующей страницы.
- Гидрация подписей. По странице постов собрать уникальные идентификаторы авторов, компаний-авторов и
категорий; загрузить их подписи батчем из локальных реплик (
AuthorReplica/CompanyReplica/
PostCategory) — без синхронного вызова в identity. Пустые реплики при лаге консюмеров → мягкая
деградация подписи (AuthorDisplayName fallback), не ошибка.
- Маппинг и результат. Собрать
PostFeedItem поле-в-поле из поста и реплик; AuthorCompany — из
CompanyReplica, если у поста есть компания-автор; BlocksPreview — первый текстовый блок (обрезка
<= 280) + число блоков; HasSelectionEmbed — наличие блока Kind = SelectionEmbed. Вернуть страницу:
Items = первые Limit элементов; HasMore — был ли лишний элемент; NextCursor — курсор последнего
отданного при HasMore, иначе null. Пустая выдача (нет постов под фильтр) — успех 200 с пустым
Items. Свежесть подписей и поисковой проекции — eventual (лаг консюмеров identity / индексатора).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | Limit вне 1..30; SearchTerm > 256; неизвестный Sort/AuthorType; повреждённый Cursor | «Проверьте параметры запроса» | no retry |
Отказ базы данных или поисковой проекции — инфраструктурная ошибка (5xx), а не бизнес-ошибка. Пустая
выдача (нет постов под фильтр) — успех 200 с пустым Items, не ошибка.
Совместимость и наблюдаемость
- Новые optional-фильтры и значения
Sort — additive (без breaking). Удаление/переименование параметра
или поля PostFeedItem, смена типа — breaking (versioning + rollout). Неизвестный Sort в запросе →
VALIDATION_ERROR (fail-fast, не тихий fallback).
- Logs:
feed queried (sort, число фильтров, resultCount, hasSearchTerm; без PII). Metrics:
news_feed_requests_total{sort,result}, latency p99, отдельная latency поиска. Traces —
сквозной trace через traceparent; dashboard news-overview.
- SLO: лента p99 < 300 мс; поиск p99 < 400 мс. Freshness подписей и поисковой проекции — eventual
(лаг консюмеров
identity / индексатора < 1 мин).
Acceptance Criteria
- Дефолтная лента. Дано: есть
Published-посты. Когда: Anonymous вызывает GET /api/v1/posts без
параметров. Тогда: 200, страница из витрины постов, только Status = Published, порядок PublishedAt desc, Id desc (recommended отложено = date, решение №10); при наличии продолжения — HasMore = true и
непустой NextCursor.
- Поиск. Дано: задан
SearchTerm (1..256). Когда: GET. Тогда: страница идентификаторов приходит из
проекции PostSearchDocument (матч по SearchText); применяются те же фильтры и условия Published +
«компания-автор не Suspended».
- Фильтры по И. Дано: заданы
CategoryId и AuthorType. Когда: GET. Тогда: в выдаче только посты,
удовлетворяющие всем фильтрам (AND).
- Suspended-компания. Дано: пост компании со статусом
Suspended. Когда: GET. Тогда: пост исключён из
выдачи (INV-REP-003).
- Blocked-автор. Дано: пост автора со статусом
Blocked. Когда: GET. Тогда: пост остаётся в ленте, его
подпись помечается при отображении (INV-REP-002 — по умолчанию не скрывает).
- Некорректные параметры. Дано:
Limit > 30, либо SearchTerm > 256, либо неизвестный Sort/AuthorType,
либо повреждённый Cursor. Когда: GET. Тогда: 400 VALIDATION_ERROR (fail-fast, без тихого fallback).
- Пустая выдача. Дано: фильтры (в т.ч. несуществующий
CategoryId/AuthorCompanyId/ProjectId) не дают
постов. Когда: GET. Тогда: 200 с Items = [] — не ошибка.
- Лаг реплик. Дано: реплика подписи автора/компании ещё не догнала (
identity-консюмер отстал). Когда:
GET. Тогда: AuthorDisplayName мягко деградирует (fallback), выдача возвращается успешно; свежесть подписей
и поисковой проекции — eventual (< 1 мин).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы