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

GET /api/v1/posts — Глобальная лента + поиск

Назначение

Единая платформенная (вне tenant) лента опубликованных постов новостей с курсорной пагинацией, полнотекстовым поиском и набором фильтров. Вызывает анонимный гость или авторизованный пользователь; получает страницу постов с уже готовыми подписями автора и компании-автора (из локальных реплик identity, без синхронного обращения в чужие контексты). Без SearchTerm лента читается из основной витрины постов; с SearchTerm — из поисковой проекции PostSearchDocument. Ранжирование отложено (решение №10, INV-4): Sort = recommended прозрачно работает как date (post-ranking-model.md).

Metadata

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

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

ПроверкаЗначение
Actor typeAnonymous (гость) или User (JWT)
Auth policy / Permissionsnone — публичное чтение опубликованного контента (news.posts.view — публичный); rate limit по IP
Scope (RBAC)платформа (лента платформенного уровня, не организация/проект)
Record-levelне применяется — выдаются только Status = Published
Tenant isolationне применяется — платформенная лента; опубликованный контент читается вне tenant (ADR-0052 п.3)
Auditread-public — отдельная audit-запись не пишется

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
SearchTermquerystring?Нет1..256 после trim; если задан — полнотекстовый поиск по проекции PostSearchDocument (поле SearchText)untrusted — только поисковый запрос, не raw-DSL/SQL
CategoryIdqueryGuid?Нетесли задан — фильтр по категории поста (существование не проверяется — пустая выдача)
AuthorTypequeryPostAuthorType?Нетenum-строкой; неизвестное значение → VALIDATION_ERROR
AuthorCompanyIdqueryGuid?Нетфильтр по компании-автору
ProjectIdqueryGuid?Нетфильтр по привязке поста к проекту застройщика (ProjectRefs.ProjectIds)
SortqueryPostFeedSortНетenum recommended/date/popularity, default recommended
Cursorquerystring?Нетopaque base64 keyset-курсор; при повреждении → VALIDATION_ERRORнепрозрачный, не PII
LimitqueryintНет1..30, default 20защита от тяжёлых страниц

Enum PostFeedSort (query, enum-строкой)

ЗначениеПорядок
recommendedотложено → PublishedAt desc, Id desc (решение №10, INV-4); при включённом ранжировании → по убыванию ranking-score
datePublishedAt desc, Id desc
popularityViewsCount desc, PublishedAt desc, Id desc

PostAuthorType — дом значений post.md (Investor/DeveloperManager/Agent/AgencyManager/PinEmployee).

Модель ответа — страница ленты (PostFeedItem)

Страница в keyset-режиме: список элементов + курсорные поля. Общее число (TotalCount) не вычисляется (перф/SLO — пагинация курсорная).

ПолеТипОбязательностьИсточникОписание
ItemsIReadOnlyList<PostFeedItem>ДаprojectionСтраница постов в порядке сортировки.
NextCursorstring?Нетcomputed (keyset)Opaque-курсор следующей страницы; null — конец ленты.
HasMoreboolДаcomputedЕсть ли ещё страницы.

PostFeedItem

ПолеТипОбязательностьИсточникMaskingОписание
PostIdGuidДаPost.IdИдентификатор поста.
SuffixstringДаPost.SuffixURL-slug (/news/{suffix}).
TitlestringДаPost.TitleЗаголовок.
Subtitlestring?НетPost.SubtitleПодзаголовок.
CoverFileIdGuid?НетPost.CoverFileIdОбложка (storage/CDN, без FK).
CategorySlugstringДаPostCategory.SlugСтабильный kebab-код категории.
AuthorDisplayNamestringДаAuthorReplica.DisplayNameПодпись автора (реплика identity).
AuthorTypePostAuthorTypeДаPost.AuthorTypeТип автора (snapshot на момент создания).
AuthorCompanyPostFeedAuthorCompany?Нетреплика (см. модель)Компания-автор (nested ref-model); null — инвестор/сотрудник.
PublishedAtDateTimeOffsetДаPost.PublishedAtМомент текущей публикации.
CommentsCountintДаPost.CommentsCountДенормализованный счётчик.
ViewsCountlongДаPost.ViewsCountДенормализованные просмотры (трекинг — user-activity).
BlocksPreviewPostBlocksPreviewДаcomputed из Post.ContentПревью содержимого (см. VO).
HasSelectionEmbedboolДаcomputed из Post.ContentЕсть ли блок Kind = SelectionEmbed.

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

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

PostBlocksPreview (typed VO ответа)

ПолеТипОбязательностьИсточникОписание
FirstTextExcerptstring?Нетпервый текстовый блок, обрезка <= 280Затравка ленты; null — нет текстовых блоков.
BlocksTotalintДаPost.Content.Blocks.CountВсего блоков в посте.

Состав блоков — post-block.md; поля поста — post.md; реплики подписей — identity-replicas.md. Внутренний AuthorUserId наружу не отдаётся; компания — через ref-модель AuthorCompany.

Алгоритм

  1. Контекст и доступ. Актор — гость (Anonymous) или авторизованный (User); JWT опционален и на выдачу не влияет — лента платформенная, вне tenant. Отдельного permission нет: чтение опубликованного контента публично.
  2. Проверка входа. SearchTerm1..256 после trim; Limit1..30; Sort — допустимое значение PostFeedSort; AuthorType — допустимое значение enum; Cursor (если задан) — корректный opaque-курсор режима (keyset для ленты, search_after-токен для поиска). Любое нарушение → VALIDATION_ERROR. Существование CategoryId/AuthorCompanyId/ProjectId не проверяется — несуществующий фильтр даёт пустую страницу.
  3. Что читаем и маршрутизация.
    • Без 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) по умолчанию из выдачи не исключается — подпись помечается при отображении.
  4. Сортировка и пагинация. Порядок — по Sort (таблица PostFeedSort): recommended (отложено) и datePublishedAt desc, Id desc; popularityViewsCount desc, PublishedAt desc, Id desc. Пагинация курсорная: для ленты — keyset от Cursor (Id как детерминированный tiebreaker); для поиска — search_after по тем же ключам сортировки. В обоих режимах читается на один элемент больше Limit, чтобы определить наличие следующей страницы.
  5. Гидрация подписей. По странице постов собрать уникальные идентификаторы авторов, компаний-авторов и категорий; загрузить их подписи батчем из локальных реплик (AuthorReplica/CompanyReplica/ PostCategory) — без синхронного вызова в identity. Пустые реплики при лаге консюмеров → мягкая деградация подписи (AuthorDisplayName fallback), не ошибка.
  6. Маппинг и результат. Собрать PostFeedItem поле-в-поле из поста и реплик; AuthorCompany — из CompanyReplica, если у поста есть компания-автор; BlocksPreview — первый текстовый блок (обрезка <= 280) + число блоков; HasSelectionEmbed — наличие блока Kind = SelectionEmbed. Вернуть страницу: Items = первые Limit элементов; HasMore — был ли лишний элемент; NextCursor — курсор последнего отданного при HasMore, иначе null. Пустая выдача (нет постов под фильтр) — успех 200 с пустым Items. Свежесть подписей и поисковой проекции — eventual (лаг консюмеров identity / индексатора).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_ERROR400Limit вне 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 мин).

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

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

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