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

GET /api/v1/public/projects — Лента published-проектов (поиск + фильтры + сортировки)

Назначение

Публичная лента карточек проектов (ЖК / апарт-отелей) — первый экран каталога и страницы /hotels: пагинация, полнотекстовый поиск по названию и адресу (Elasticsearch-проекция ProjectSearchDocument, ADR-0059), фильтры «тип / класс / регион / город / статус строительства / ценовой диапазон» и сортировки «цена / дата добавления / окупаемость». Фильтры «регион»/«город» — по ссылкам справочника (RegionId/CityId, handbook-replica.md); display-имена в карточках — денормализованные RegionName/CityName проекта. Owning-контекст — chessboard; лента строится поверх source of truth chessboard.projects (структурная выборка).

Доступ без гейта: анонимный гость видит ленту с урезанными полями — точные цены скрыты. Зеркалируется в agents area (GET /api/v1/agents/projects) той же операцией, но с обязательной авторизацией агента — IsAuthenticated всегда true, masking отключён.

Границы метода

Ранжирование, пакеты продвижения и оверрайды отображения витрины — контекст catalog (../../../../catalog/api/public/get-projects.md); здесь — структурная лента поверх шахматки. Запись просмотров/поиска в историю — user-activity, не этот метод.

Metadata

ПолеЗначение
Сервис/контекстchessboard
API areapublic (зеркало: agents)
Feature groupprojects
Статусdraft
OwnerPIN Platform Core
Документdocs/06-services/chessboard/api/public/projects/search-projects.md

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

ПроверкаЗначение
Actor typeAnonymous (ограниченная лента) / User — инвестор, агент, менеджер застройщика (полная лента)
Auth policy / Permissionsnone — public endpoint; полнота полей определяется признаком авторизации
Scope (RBAC)не применяется (платформенное чтение published-среза)
Record-levelне применяется
Tenant isolationКонтролируемый bypass tenant-фильтра только для среза Project.Status = Published && Developer.Status = Published (ADR-0052, INV-DEV-6); тот же срез применяется к полнотекстовому поиску
Auditnone (read; просмотры/поиск трекает user-activity)

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
IsAuthenticatedidentity claimsboolДа (вычисляется)из контекста авторизации; аноним = false; agents-зеркало = всегда trueопределяет masking
ActorUserIdidentity claimsGuid?Нетnull для анонимане логируется в теле ответа
PagequeryintНет (default 1)>= 1
PageSizequeryintНет (default 20)1..100
Searchquerystring?Нет<= 256 симв.; полнотекстовый поиск по Name + Address через Elasticsearch-проекцию ProjectSearchDocument (ADR-0059)untrusted; передаётся только как поисковый терм (не raw-DSL); лог — 64 симв.
Typesquery (csv)ProjectType[]?НетResidentialComplex/ApartHotel/Hotel/Villa (reference-data.md); пусто = все
Classesquery (csv)ProjectClass[]?НетStandard/Comfort/Business/Premium/Elite
RegionIdsquery (csv)long[]?Нет≤ 20 элементов, каждый > 0 — ссылки справочника регионов (handbook-replica.md); членство RegionId in (…) (ix_projects_region_id); неизвестный/архивный id даёт пустое пересечение, не ошибку
CityIdsquery (csv)long[]?Нет≤ 20 элементов, каждый > 0 — ссылки справочника городов; членство CityId in (…) (ix_projects_city_id); неизвестный/архивный id даёт пустое пересечение, не ошибку
ConstructionStatusesquery (csv)ConstructionStatus[]?НетPlanned/UnderConstruction/Commissioned/Delivered
Pricequery (Price.Min/Price.Max)NullableRange<decimal>?НетPrice.Min >= 0, Price.Max >= 0, при обоих — Price.Min <= Price.Max; пересечение с диапазоном проекта
SortByqueryPublicProjectSortByНет (default CreatedAtDesc)enum: CreatedAtDesc/CreatedAtAsc/PriceAsc/PriceDesc/PaybackYearsAsc/PaybackYearsDesc

Enum PublicProjectSortBy

ЗначениеПорядокКлюч (индекс)
CreatedAtDesc (default)по дате добавления, новые первымиCreatedAt desc (ix_projects_created_at)
CreatedAtAscпо дате добавления, старые первымиCreatedAt asc
PriceAsc / PriceDescпо ценеPrice.Min nulls last (ix_projects_price_min)
PaybackYearsAsc / PaybackYearsDescпо окупаемостиPaybackYears nulls last

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

ПолеТипОбязательностьИсточникMaskingОписание
ProjectsСтраница PublicProjectFeedCardДастраница выборкисм. карточкуСтраница карточек ленты (Items, Page, PageSize, TotalCount, TotalPages).
IsLimitedboolДавычисляется: !IsAuthenticatedtrue — отдана ограниченная лента (цены скрыты); фронт показывает CTA «войдите».

Карточка PublicProjectFeedCard (проекция ProjectEntity)

Masking-колонка — правило для анонима (IsAuthenticated = false); авторизованный получает значение всегда.

ПолеТипОбязательностьИсточникMasking (аноним)Описание
IdGuidДаProject.IdИдентификатор проекта.
NamestringДаProject.NameНазвание ЖК/отеля.
TypeProjectType (string)ДаProject.TypeТип (reference-data.md).
ClassProjectClass (string)ДаProject.ClassКласс.
ConstructionStatusConstructionStatus (string)ДаProject.ConstructionStatusСтатус строительства.
RegionIdlongДаProject.RegionIdСсылка на регион справочника (у Published-проекта резолвлена, INV-PRJ-3).
RegionNamestringДаProject.RegionName (денормализация)Display-имя региона.
CityIdlongДаProject.CityIdСсылка на город справочника (INV-PRJ-3).
CityNamestringДаProject.CityName (денормализация)Display-имя города.
Starsshort?НетProject.StarsЗвёздность отеля (Type in (ApartHotel, Hotel)).
CoverImageFileIdGuid?НетProject.CoverImageFileIdОбложка карточки (пресайн — storage/CDN).
PriceNullableRange<decimal>?НетProject.Price (денормализация, Price.Min/Price.Max)null (точные цены скрыты)Диапазон цен свободных объектов недвижимости.
CurrencyIdshortДаProject.CurrencyIdСсылка на валюту справочника (RUB-only, ADR-0048); display-код/знак — публичный lookup handbook.
PropertyObjectsTotalCountintДаProject.PropertyObjectsTotalCountВсего объектов недвижимости (карточка «кол-во объектов»).
PaybackYearsdecimal?НетProject.PaybackYearsОкупаемость, лет (детальный расчёт — invest-analytics).
DeveloperDeveloperFeedSummaryДаjoin DeveloperEntityЗастройщик-владелец (ниже).

PropertyObjectsFreeCount в ленту не проецируется (masked-показатель свободных объектов недвижимости живёт в карточке проекта — get-project.md); в этой ленте маскируются только цены.

Вложенная модель DeveloperFeedSummary (проекция developer.md)

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDeveloper.Id= CompanyId identity.
NamestringДаDeveloper.NameНазвание застройщика.
LogoFileIdGuid?НетDeveloper.LogoFileIdЛого (пресайн — storage/CDN).

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

Предусловия:

  • Вызов анонимом (ограниченная лента) или авторизованным пользователем (полная); признак авторизации определяет masking. Agents-зеркало требует авторизацию агента (IsAuthenticated = true, masking отключён).
  • Публичный срез: проекты и застройщики Published (INV-DEV-6); тот же срез применяется к полнотекстовому поиску (published-фильтр ES-проекции, ADR-0014/0059).

Постусловия (read — состояние не меняется):

  • Возвращена страница карточек и IsLimited; для анонима Price = null в каждой карточке; фильтрация и сортировка по цене остаются серверными даже при маскировании выхода (сервер знает точные цены, наружу их не отдаёт).
  • Пустая выдача — валидный 200 c Items = [].
  • Freshness: денормализация Price/PropertyObjectsTotalCount отстаёт до debounce-джоба project-aggregates-recalc (30 с); лаг ES-проекции ProjectSearchDocument — < 1 мин от project-upserted (search/projects.md «Freshness»), поэтому только что опубликованный/снятый проект может кратковременно расходиться между структурной выборкой и полнотекстовым набором.

Алгоритм

Постраничная лента карточек проектов по published-срезу: опциональный полнотекстовый поиск сужает набор id, затем структурная выборка с фильтрами и сортировкой; для анонима цены маскируются.

  1. Контекст и доступ. Вызывает аноним (ограниченная лента) или авторизованный пользователь (полная); признак авторизации определяет masking. Agents-зеркало требует авторизацию агента (IsAuthenticated = true, masking отключён). Public-лента без гейта. Чтение — по published-срезу (проекты и застройщики Published).
  2. Проверка входа. Page >= 1, PageSize в 1..100, длина Search ≤ 256, границы ценового диапазона >= 0 и Price.Min <= Price.Max, SortBy из перечня → иначе ValidationError. Семантически несовместимый фильтр (неизвестное enum-значение Types/Classes/ConstructionStatuses после разбора csv, Price.Min > Price.Max, SortBy вне перечня) → CHESSBOARD_PROJECTS_FILTER_INVALID. Id-входов нет — существование сущностей не проверяется; допустимость состояния задаёт жёсткий предикат Status = Published.
  3. Что читаем.
    • Если задан Search — полнотекстовый матч по Elasticsearch-проекции ProjectSearchDocument (../../../search/projects.md, ADR-0059): multi-match по Name/NameTranslit/Address/RegionName (fuzziness, транслитерация) с published-фильтром (ADR-0014) → relevance-упорядоченный набор ProjectId. Прямой клиент Elasticsearch в обработчике запрещён (ADR-0059).
    • Карточки читаются одной постраничной проекцией с join на застройщика (без загрузки агрегатов):
      • публичный срез: проект Published и не удалён; застройщик Published и не удалён (INV-DEV-6 HiddenCascades — скрытый застройщик убирает все свои проекты);
      • если задан Search — только проекты из набора id выше;
      • Types/Classes/ConstructionStatuses — членство в наборе (индексы ix_projects_type/_class/ _construction_status); RegionIds/CityIds — членство в наборе ссылок справочника (ix_projects_region_id/ix_projects_city_id);
      • ценовой диапазон — пересечение отрезков Price.Max >= @Min && Price.Min <= @Max (ix_projects_price_min);
      • сортировка — по SortBy (таблица enum выше). Тем же запросом считается TotalCount.
  4. Сборка ответа и masking. Карточка — по таблице PublicProjectFeedCard (плоские значения проекта, Price — диапазон, застройщик → DeveloperFeedSummary). При анониме — Price = null в каждой карточке, IsLimited = true; для авторизованного значения отдаются как есть, IsLimited = false. Фильтрация и сортировка по цене остаются серверными даже при маскировании выхода (сервер знает точные цены, наружу не отдаёт).
  5. Результат. Страница карточек и IsLimited, статус 200; пустая выдача — 200 c Items = []. Freshness: read-committed снимок; денормализация Price/PropertyObjectsTotalCount отстаёт до debounce-джоба project-aggregates-recalc (30 с); лаг Elasticsearch-проекции — < 1 мин от project-upserted (search/projects.md «Freshness»).

Диаграмма

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

ТипДетали
Integration eventsnone
External callsElasticsearch (только при заданном Search) — чтение проекции ProjectSearchDocument (ADR-0059); пресайн обложек/лого — клиент через storage/CDN по FileId
Cache / projections / searchполнотекстовый Search — Elasticsearch-проекция ProjectSearchDocument (../../../search/projects.md, ADR-0059); фасеты/сортировка/пагинация — PostgreSQL read-model (btree ix_projects_*); кэша нет (ADR-0056)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Нарушение формата/min/max/шаблона/enum входных полейСписок ошибок полейno retry
CHESSBOARD_PROJECTS_FILTER_INVALID400Семантически несовместимый фильтр (Price.Min > Price.Max, неизвестный enum после парсинга csv, SortBy вне enum)«Проверьте параметры фильтра»retry после исправления
INTERNAL_ERROR500Инфраструктурный сбой (PostgreSQL или Elasticsearch недоступны)genericretry (client backoff)

Пустая выдача — 200 c Projects.Items = [] (не ошибка).

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

  • Новые optional фильтры/поля карточки — additive (без breaking); удаление/переименование/смена типа поля или ослабление masking в сторону раскрытия цен гостю — breaking (новая версия route + rollout).
  • Enum-поля сериализуются строками; клиент обязан иметь fallback на unknown-значения (reference-data.md).
  • Logs: actor type (anonymous/user), фильтры (значение Search — первые 64 симв.), Page/PageSize; без PII. Metrics: chessboard_public_projects_search_total{authenticated}, histogram latency (SLO p99 < 300 мс — README «Runtime И Эксплуатация»), доля маскированных ответов. Traces: span chessboard.search-public-projects со сквозной корреляцией. Dashboard/runbook: chessboard-overview.

Acceptance Criteria

  • AC1 (лента по умолчанию для анонима). Дано: аноним, есть published-проекты. Когда: GET без фильтров. Тогда: 200, порядок CreatedAtDesc, Price = null в карточках, IsLimited = true.
  • AC2 (полная лента для авторизованного). Дано: авторизованный пользователь. Когда: GET. Тогда: Price заполнен, IsLimited = false.
  • AC3 (полнотекстовый поиск). Дано: задан Search. Когда: GET. Тогда: набор id сужен ES-матчем по Name/NameTranslit/Address/RegionName (published-срез), карточки читаются из PostgreSQL по этим id в порядке релевантности.
  • AC4 (фильтры + сортировка по цене). Дано: Types/Classes/Price и SortBy = PriceAsc. Когда: GET анонимом. Тогда: лента отфильтрована и упорядочена по цене серверно, но Price в выдаче = null.
  • AC5 (несовместимый фильтр). Дано: Price.Min > Price.Max или неизвестный enum после разбора csv. Когда: GET. Тогда: CHESSBOARD_PROJECTS_FILTER_INVALID (400).
  • AC6 (границы пагинации). Дано: PageSize вне 1..100 или Page < 1. Когда: GET. Тогда: ValidationError (400).
  • AC7 (пустая выдача). Дано: фильтр без совпадений. Когда: GET. Тогда: 200 c Projects.Items = [] (не ошибка).
  • AC8 (каскад скрытия застройщика). Дано: застройщик не Published. Когда: GET. Тогда: его проекты в ленте отсутствуют (INV-DEV-6).
  • AC9 (фильтр по ссылкам справочника). Дано: RegionIds/CityIds с существующими id. Когда: GET. Тогда: в выдаче только проекты с этими RegionId/CityId; неизвестный или архивный id даёт пустое пересечение (200, не ошибка).

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

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

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