«Каталка»: Public API для мобильного фронта
Версия документа: 1.1.10 Статус: официальный контракт для фронта Актуальность: 2 сентября 2026 года
Источник контрактов: актуальные backend-контракты на 2 сентября 2026 года. Версия 1.1.10 актуализирует публичный weather-контракт: добавлены статусы доступности погодных данных, NO_SNOW, кешируемое alpine/snow-обогащение погоды курорта, уточнена семантика null и 0, а координатные weather-запросы отделены от курортных alpine-данных.
Документ охватывает 12 контроллеров, в имени которых есть Public, — 53 endpoint-а, а также пользовательский AuthController. В актуальный frontend-контракт Auth входят 17 endpoint-ов; legacy-upload change-avatar по-прежнему не публикуется.
1. Общие правила
Базовый адрес
https://api.katalka.ski
API-префикс большинства методов:
/api/v1
Публичные media-файлы выдаются отдельно:
/media/{mediaPath}
Авторизация
- Большинство
GET-методов каталогов Public-контроллеров доступны без JWT. Исключение — пользовательские закладкиFavoritePublicController: все/api/v1/favorites/**требуют JWT. ВAuthControllerисключения указаны явно:GET /api/v1/auth/avatarsпубличный, аGET /api/v1/auth/meтребует JWT. - Все
POST .../day-off-plans/previewдоступны без JWT и создают предварительный персональный план без сохранения. - Все
POST .../day-off-plans/generateтребуют заголовокAuthorization: Bearer <JWT>и создают сохранённый план текущему пользователю. userIdдля сохранённого day-off plan берётся только изsubJWT; передавать его в JSON не нужно.- Публичный блок «День без каталки» на главной странице не является
DayOffPlan: он загружается без JWT черезGET /api/v1/places?group=DAY_OFF.
Форматы
UUID: строка вида7e17f688-7bcb-4f25-b63d-f918d06958f3.LocalDate:YYYY-MM-DD.OffsetDateTime: ISO 8601, например2026-08-05T10:30:00+03:00.- Enum передаются строками в верхнем регистре.
- Списковые методы возвращают непосредственно JSON-массив, без обёртки
data,contentили пагинации. - Nullable-поля могут возвращаться как
null. - Гибкие JSON-поля (
tags,links,displaySettingsи т. п.) возвращаются объектом{}при отсутствии данных.
Заголовки
Для обычного чтения:
Accept: application/json
Для POST:
Accept: application/json
Content-Type: application/json; charset=utf-8
Для запроса POST .../day-off-plans/generate дополнительно:
Authorization: Bearer <JWT>
Ошибка API
Общий формат ошибки backend:
{
"timestamp": "2026-08-05T10:30:00+03:00",
"status": 404,
"error": "NOT_FOUND",
"message": "Resource not found",
"path": "/api/v1/..."
}
Основные статусы для клиента:
| Статус | Значение |
|---|---|
200 | Успешный ответ |
400 | Некорректный enum, UUID, JSON или обязательное поле |
401 | Нет JWT или JWT некорректен для защищённого метода |
403 | Доступ запрещён |
404 | Активная сущность или media-файл не найдены |
500 | Необработанная ошибка backend |
2. Быстрый список endpoint-ов
| Контроллер | Endpoint-ов | Назначение |
|---|---|---|
ResortPublicController | 3 | Список и карточка курорта |
PlacePublicController | 10 | Места, категории, случайная и редакционная карточка |
EventPublicController | 8 | Глобальные и локальные события |
BannerPublicController | 4 | Глобальные и курортные баннеры |
CollectionPublicController | 6 | Подборки и их наполненные элементы |
ArticlePublicController | 3 | Опубликованные статьи |
CameraPublicController | 2 | Камеры курорта |
WeatherPublicController | 4 | Короткая и подробная погода по координатам или курорту |
DayOffPlanPublicController | 4 | Публичный preview и авторизованное сохранение персонального плана |
ResortMapPublicController | 4 | Глобальная карта, map package и GeoJSON |
MediaPublicFileController | 1 | Файлы изображений и других media |
FavoritePublicController | 4 | Закладки текущего пользователя |
3. Курорты — ResortPublicController
Методы
| Метод | URL | Ответ | Поведение |
|---|---|---|---|
GET | /api/v1/resorts | ResortResponse[] | Все активные курорты; текущая сортировка name ASC |
GET | /api/v1/resorts/{id} | ResortResponse | Активный курорт по UUID |
GET | /api/v1/resorts/by-slug/{slug} | ResortResponse | Активный курорт по slug |
curl.exe -sS "https://api.katalka.ski/api/v1/resorts"
curl.exe -sS "https://api.katalka.ski/api/v1/resorts/by-slug/sheregesh"
ResortResponse
type ResortResponse = {
id: UUID;
slug: string;
name: string;
shortDescription: string | null;
description: string | null;
imageUrl: string | null;
image: MediaAssetResponse | null;
gallery: string[];
latitude: number | null;
longitude: number | null;
radius: number; // радиус территории курорта, км
address: string | null;
region: string | null;
group: ResortGroupShortResponse | null;
phoneMain: string | null;
phoneEmergency: string | null;
partner: boolean;
active: boolean;
sortOrder: number | null;
metaTags: ResortMetaTag[];
searchAliases: string[];
previewTags: ResortPreviewTags | null;
characteristics: Record<string, unknown>;
seasonInfo: Record<string, unknown>;
priceInfo: Record<string, unknown>;
routeInfo: Record<string, unknown>;
links: Record<string, unknown>;
features: ResortFeatureResponse[];
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
radius — радиус территории/геозоны конкретного курорта в километрах. Backend хранит значение в resorts.radius_km; для существующих курортов базовое значение — 10.000.
group — активная группа курортов, в которую входит Resort. Если курорт не входит в группу, возвращается null.
type ResortGroupShortResponse = {
id: UUID;
slug: string;
name: string;
};
features содержит только активные особенности и сортируется sortOrder ASC, затем id ASC.
type ResortFeatureResponse = {
id: UUID;
externalKey: string;
name: string;
description: string | null;
previewMedia: MediaAssetResponse | null;
coverMedia: MediaAssetResponse | null;
coverPositionX: number; // 0..100
coverPositionY: number; // 0..100
sortOrder: number;
active: boolean;
};
type ResortPreviewTags = {
infrastructure: "TOP_SERVICE" | "BASIC_MINIMUM" | "WILD_COLOR" | null;
format: "WEEKEND" | "VACATION" | "EXOTIC" | null;
budget: "PREMIUM" | "ECONOMY" | "MID_RANGE" | null;
ridingSpecific: Array<"FREERIDE" | "TRAILS" | "SNOWPARK">;
vibe: Array<"FAMILY" | "PARTY" | "RELAX">;
};
Для блока «Особенности» фронту нужно использовать features, а не старые media с ролью STORY.
ResortMetaTag — строковый enum. Клиенту безопаснее обрабатывать неизвестное значение как новый тег, а не считать его ошибкой декодирования: список тегов будет расширяться вместе с контентной моделью.
4. Места — PlacePublicController
Глобальные методы
| Метод | URL | Ответ |
|---|---|---|
GET | /api/v1/places?type={type}&group={groupCode} | PlaceCardResponse[] |
GET | /api/v1/places/by-type | PlaceTypeGroupResponse[] |
GET | /api/v1/places/random?type={type}&group={groupCode} | PlaceDetailsResponse |
GET | /api/v1/places/featured?type={type}&group={groupCode} | PlaceDetailsResponse |
GET | /api/v1/places/{placeId} | PlaceDetailsResponse |
Методы внутри курорта
| Метод | URL | Ответ |
|---|---|---|
GET | /api/v1/resorts/{resortId}/places?type={type}&group={groupCode} | PlaceCardResponse[] |
GET | /api/v1/resorts/{resortId}/places/by-type | PlaceTypeGroupResponse[] |
GET | /api/v1/resorts/{resortId}/places/random?type={type}&group={groupCode} | PlaceDetailsResponse |
GET | /api/v1/resorts/{resortId}/places/featured?type={type}&group={groupCode} | PlaceDetailsResponse |
GET | /api/v1/resorts/{resortId}/places/by-slug/{slug} | PlaceDetailsResponse |
Оба query-параметра необязательны и могут комбинироваться. group нормализуется backend-ом в верхний регистр.
curl.exe -sS "https://api.katalka.ski/api/v1/places?type=RESTAURANT"
curl.exe -sS "https://api.katalka.ski/api/v1/places?group=DAY_OFF"
curl.exe -sS "https://api.katalka.ski/api/v1/resorts/RESORT_UUID/places/featured?type=PHOTO_SPOT"
Поведение
- Списки:
sortOrder ASC, затемname ASC, затемid ASC. featured: первая сущность в этой редакционной сортировке.random: случайная сущность из отфильтрованного набора; при повторном запросе результат может измениться.- В глобальные методы входят только активные места активных курортов.
by-typeне возвращает пустые группы.GET /api/v1/places?group=DAY_OFF— публичная редакционная выдача карточек для блока «День без каталки» на главной странице. Это не персональный план и JWT для неё не нужен.
Типы мест
RESTAURANT, CAFE, BAR, SPA, BATH, HOTEL, VIEWPOINT, PHOTO_SPOT,
SHOP, MEDICAL, RESCUE, PARKING, TOILET, ACTIVITY, RENTAL,
TRANSPORT, OTHER
DTO
type PlaceCardResponse = {
id: UUID;
resort: PlaceResortResponse;
slug: string;
name: string;
type: PlaceType;
groupCode: string | null;
shortDescription: string | null;
image: MediaAssetResponse | null;
imageUrl: string | null;
sortOrder: number;
};
type PlaceTypeGroupResponse = {
type: PlaceType;
items: PlaceCardResponse[];
};
type PlaceDetailsResponse = {
id: UUID;
resort: PlaceResortResponse;
slug: string;
name: string;
type: PlaceType;
groupCode: string | null;
shortDescription: string | null;
description: string | null;
latitude: number | null;
longitude: number | null;
address: string | null;
tags: Record<string, unknown>;
searchAliases: string[];
contacts: Record<string, unknown>;
workingHours: Record<string, unknown>;
priceInfo: Record<string, unknown>;
coverMedia: MediaAssetResponse | null;
media: PlaceMediaResponse[];
sortOrder: number;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
type PlaceResortResponse = {
id: UUID;
slug: string;
name: string;
region: string | null;
sortOrder: number | null;
};
type PlaceMediaResponse = {
id: UUID;
placeId: UUID;
mediaAsset: MediaAssetResponse;
role: "COVER" | "GALLERY" | "MENU" | "INTERIOR" | "EXTERIOR";
sortOrder: number;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
5. События — EventPublicController
| Метод | URL | Ответ | Выборка |
|---|---|---|---|
GET | /api/v1/events | EventResponse[] | Глобальные активные: resortId = null |
GET | /api/v1/events/upcoming | EventResponse[] | Глобальные, startTime >= now |
GET | /api/v1/events/local | EventResponse[] | Локальные активные всех курортов |
GET | /api/v1/events/local/upcoming | EventResponse[] | Локальные всех курортов, startTime >= now |
GET | /api/v1/events/{id} | EventResponse | Активное событие по UUID |
GET | /api/v1/events/resort/{resortId} | EventResponse[] | Активные события курорта |
GET | /api/v1/events/resort/{resortId}/type/{type} | EventResponse[] | События курорта по типу |
GET | /api/v1/events/resort/{resortId}/slug/{slug} | EventResponse | Событие курорта по slug |
Основная выдача для блока событий на главной сейчас:
curl.exe -sS "https://api.katalka.ski/api/v1/events/local"
curl.exe -sS "https://api.katalka.ski/api/v1/events/local/upcoming"
Типы событий:
FESTIVAL, SPORT, CONCERT, PARTY, EXCURSION, FAMILY, FOOD,
MARKET, COMPETITION, OTHER
type EventResponse = {
id: UUID;
resortId: UUID | null;
slug: string;
name: string;
shortDescription: string | null;
description: string | null;
type: EventType;
startTime: ISODateTime;
endTime: ISODateTime | null;
locationName: string | null;
latitude: number | null;
longitude: number | null;
coverMedia: MediaAssetResponse | null;
searchAliases: string[];
tags: Record<string, unknown>;
media: EventMediaResponse[];
links: Record<string, unknown>;
sortOrder: number;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
type EventMediaResponse = {
id: UUID;
eventId: UUID;
mediaAsset: MediaAssetResponse;
role: "COVER" | "GALLERY" | "POSTER" | "SCHEDULE" | "VIDEO";
sortOrder: number;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
Особенности текущей реализации:
- Общие списки сортируются
sortOrder ASC, затемstartTime ASC. /resort/{resortId}сортируется толькоstartTime ASC.- Если
shortDescriptionпуст, backend строит его изdescriptionи обрезает до 120 символов. - Сейчас
mediaв ответе принудительно возвращается пустым массивом[]; доступно толькоcoverMedia.
6. Баннеры — BannerPublicController
| Метод | URL | Параметры | Ответ |
|---|---|---|---|
GET | /api/v1/banners | placement?, slug?, limit? | BannerResponse[] |
GET | /api/v1/resorts/{resortId}/banners | placement?, limit? | BannerResponse[] |
GET | /api/v1/banners/{bannerId} | — | BannerResponse |
GET | /api/v1/banners/by-slug/{slug} | — | BannerResponse |
limit: по умолчанию 50, максимум 100; 0 и отрицательные значения заменяются на 50.
Показываются только баннеры, для которых:
active = true;activeFromотсутствует или уже наступил;activeToотсутствует или ещё не закончился.
Сортировка списков: priority DESC, затем createdAt DESC, затем id ASC.
Placement:
HOME, LIVE, RESORT_CARD, PROFILE, SEARCH, ARTICLE, GLOBAL
curl.exe -sS "https://api.katalka.ski/api/v1/banners?placement=GLOBAL&limit=10"
type BannerResponse = {
id: UUID;
slug: string;
name: string;
description: string | null;
placement: BannerPlacement;
imageMedia: MediaAssetResponse | null;
imageUrl: string | null;
resortId: UUID | null;
targetType: "NONE" | "INTERNAL_SCREEN" | "RESORT" | "PLACE" |
"EVENT" | "ARTICLE" | "COLLECTION" | "EXTERNAL_URL";
targetPayload: Record<string, unknown>;
targetingRules: Record<string, unknown>;
activeFrom: ISODateTime | null;
activeTo: ISODateTime | null;
priority: number;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
7. Подборки — CollectionPublicController
Методы
| Метод | URL | Параметры | Ответ |
|---|---|---|---|
GET | /api/v1/collections | type?, placement?, slug? | CollectionResponse[] |
GET | /api/v1/collections/with-items | type?, placement?, slug? | CollectionWithItemsResponse[] |
GET | /api/v1/resorts/{resortId}/collections | type?, placement? | CollectionResponse[] |
GET | /api/v1/resorts/{resortId}/collections/with-items | type?, placement? | CollectionWithItemsResponse[] |
GET | /api/v1/collections/{collectionId} | — | CollectionResponse |
GET | /api/v1/collections/{collectionId}/items | — | CollectionItemResponse[] |
Query-фильтры комбинируются:
curl.exe -sS "https://api.katalka.ski/api/v1/collections?type=MIXED&placement=GLOBAL"
curl.exe -sS "https://api.katalka.ski/api/v1/collections/with-items?slug=kurorty-kavkaza"
Типы содержимого подборок (type):
RESORTS, PLACES, EVENTS, ARTICLES, MIXED
type описывает тип сущностей внутри коллекции и не определяет смысловой раздел интерфейса. Для смыслового назначения используется отдельное поле collectionType. Фронт не должен определять раздел по slug.
Смысловые типы коллекций (collectionType):
DAY_OFF, PHOTO_SPOTS, FOOD, RENTAL, RESORT, PLACES
Основные соответствия:
collectionType | Назначение |
|---|---|
DAY_OFF | День без каталки |
PHOTO_SPOTS | Инсталокации / фотоместа |
FOOD | Где поесть |
RENTAL | Прокат |
RESORT | Курорты |
PLACES | Обычный раздел мест |
Примеры независимости полей:
{
"type": "PLACES",
"collectionType": "FOOD"
}
{
"type": "PLACES",
"collectionType": "PHOTO_SPOTS"
}
{
"type": "RESORTS",
"collectionType": "RESORT"
}
Для legacy-коллекций backend может вычислять collectionType из существующих признаков. Для типов, для которых смысловой раздел не задан и не может быть однозначно вычислен, поле может быть null.
Placement подборок:
HOME, SEARCH, LIVE, RESORT_CARD, PROFILE, GLOBAL
Типы элементов:
RESORT, PLACE, EVENT, ARTICLE, BANNER, DAY_OFF_PLAN
Сортировка подборок: sortOrder ASC, затем name ASC. Элементы внутри подборки: sortOrder ASC.
Выбор метода
- Используйте
/collections, если на экране нужны только заголовки и обложки подборок. - Используйте
/collections/with-items, если содержимое нужно сразу. /collections/{id}/itemsподходит для отложенной загрузки наполнения выбранной подборки.- У элемента поле
targetможет бытьnull, если целевая сущность удалена или неактивна. Фронт должен такой элемент безопасно пропустить.
DTO
type CollectionCategory =
| "DAY_OFF"
| "PHOTO_SPOTS"
| "FOOD"
| "RENTAL"
| "RESORT"
| "PLACES";
type CollectionResponse = {
id: UUID;
slug: string;
name: string;
description: string | null;
type: CollectionType;
collectionType: CollectionCategory | null;
placement: CollectionPlacement;
coverMedia: MediaAssetResponse | null;
resortId: UUID | null;
displaySettings: Record<string, unknown>;
sortOrder: number;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
type CollectionWithItemsResponse = CollectionResponse & {
items: CollectionItemResponse[];
};
type CollectionItemResponse = {
id: UUID;
collectionId: UUID;
targetType: CollectionItemTargetType;
targetId: UUID;
target: CollectionItemTargetResponse | null;
sortOrder: number;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
type CollectionItemTargetResponse = {
id: UUID;
type: CollectionItemTargetType;
slug: string | null;
name: string | null;
region: string | null;
latitude: number | null;
longitude: number | null;
altitudeMeters: number | null;
shortDescription: string | null;
description: string | null;
imageUrl: string | null;
coverMedia: MediaAssetResponse | null;
resortId: UUID | null;
resortSlug: string | null;
resortName: string | null;
sortOrder: number | null;
active: boolean | null;
};
collectionType — смысловой тип коллекции. Он не дублирует type: например, коллекции «Где поесть», «Инсталокации», «Прокат» и обычные «Места» могут иметь одинаковый type = PLACES, но разные collectionType. Клиент должен использовать collectionType для выбора раздела/представления и не анализировать slug.
В CollectionItemTargetResponse координаты относятся к самой базовой сущности target, а не автоматически наследуются от связанного курорта:
RESORT—latitude,longitudeиaltitudeMetersберутся из Resort;PLACE—latitudeиlongitudeберутся из Place,altitudeMetersпокаnull;EVENT—latitudeиlongitudeберутся из Event,altitudeMetersпокаnull;BANNER,ARTICLE,DAY_OFF_PLAN— координаты и высотаnull, если у самой сущности соответствующих данных нет.
altitudeMeters задаётся в метрах над уровнем моря и является nullable. Клиент не должен подставлять высоту связанного курорта вместо отсутствующей высоты конкретного PLACE или EVENT.
Для DAY_OFF_PLAN backend возвращает техническую карточку с name = "День без каталки"; часть остальных полей будет null.
8. Статьи — ArticlePublicController
| Метод | URL | Ответ |
|---|---|---|
GET | /api/v1/articles?category={category} | ArticleResponse[] |
GET | /api/v1/articles/{articleId} | ArticleResponse |
GET | /api/v1/articles/by-slug/{slug} | ArticleResponse |
Выдаются только статьи со статусом PUBLISHED. Список сортируется publishedAt DESC, затем id ASC; статьи без даты публикации идут в конце.
Категории:
NEWS, GUIDE, BLOG, SAFETY, RESORT_REVIEW, ANNOUNCEMENT, OTHER
curl.exe -sS "https://api.katalka.ski/api/v1/articles?category=NEWS"
type ArticleResponse = {
id: UUID;
slug: string;
name: string;
excerpt: string | null;
content: string | null;
category: ArticleCategory;
status: "PUBLISHED";
coverMedia: MediaAssetResponse | null;
publishedAt: ISODateTime | null;
authorName: string | null;
seo: Record<string, unknown>;
tags: Record<string, unknown>;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
9. Камеры — CameraPublicController
| Метод | URL | Ответ |
|---|---|---|
GET | /api/v1/resorts/{resortId}/cameras | CameraResponse[] |
GET | /api/v1/resorts/by-slug/{resortSlug}/cameras | CameraResponse[] |
Возвращаются только активные камеры активного курорта, сортировка sortOrder ASC.
curl.exe -sS "https://api.katalka.ski/api/v1/resorts/by-slug/sheregesh/cameras"
type CameraResponse = {
id: UUID;
resortId: UUID;
resortSlug: string;
externalKey: string | null;
slug: string;
name: string;
description: string | null;
streamUrl: string;
previewImageUrl: string | null;
previewMedia: MediaAssetResponse | null;
locationName: string | null;
latitude: number | null;
longitude: number | null;
sortOrder: number;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
10. Погода — WeatherPublicController
Публичный weather-контракт не зависит от конкретного внешнего поставщика погоды. Backend нормализует данные внешних источников во внутреннюю модель «Каталки», поэтому frontend не должен зависеть от названия провайдера, его исходных кодов или способа кеширования.
Основной прогноз температуры, ветра, облачности, осадков и солнца формируется backend-ом из основного weather-provider. Для погоды конкретного курорта backend дополнительно использует заранее обновляемые alpine/snow-данные. Эти данные обновляются сервером отдельно от пользовательского запроса, поэтому frontend не должен самостоятельно запрашивать внешний snow/alpine-provider.
Методы
| Метод | URL | Ответ | Назначение |
|---|---|---|---|
GET | /api/v1/weather/nearby?lat={lat}&lng={lng}&altitudeMeters={altitude?} | WeatherNearbyResponse | Короткая погода рядом с пользователем по координатам |
GET | /api/v1/weather/details?lat={lat}&lng={lng}&altitudeMeters={altitude?} | WeatherDetailsResponse | Подробная погода по координатам |
GET | /api/v1/resorts/{resortId}/weather/nearby | WeatherNearbyResponse | Короткая погода выбранного курорта |
GET | /api/v1/resorts/{resortId}/weather/details | WeatherDetailsResponse | Подробная погода выбранного курорта |
Все четыре метода публичные и не требуют JWT.
Для запросов по координатам:
latобязателен и должен быть в диапазоне-90..90;lngобязателен и должен быть в диапазоне-180..180;altitudeMetersнеобязателен; если передан, backend учитывает высоту точки при запросе прогноза;- координатный weather-запрос не привязан к курорту и не использует курортный alpine/snow-cache.
Для запросов по курорту backend использует latitude, longitude и, если задано, altitudeMeters самого активного Resort. Курортные методы дополнительно получают snow/alpine-данные из серверного cache.
curl.exe -sS "https://api.katalka.ski/api/v1/weather/nearby?lat=52.9535&lng=87.9554&altitudeMeters=1200"
curl.exe -sS "https://api.katalka.ski/api/v1/weather/details?lat=52.9535&lng=87.9554&altitudeMeters=1200"
curl.exe -sS "https://api.katalka.ski/api/v1/resorts/RESORT_UUID/weather/nearby"
curl.exe -sS "https://api.katalka.ski/api/v1/resorts/RESORT_UUID/weather/details"
Короткая погода
WeatherNearbyResponse предназначен для компактного блока погоды на Live-экране и в карточках.
type WeatherNearbyResponse = {
resortId: UUID | null;
resortName: string | null;
latitude: number;
longitude: number;
updatedAt: ISODateTime;
current: WeatherCurrent | null;
wind: WeatherWind | null;
snow: WeatherSnow | null;
visibility: WeatherVisibility | null;
alpineDataStatus: WeatherDataStatus;
};
Для координатного запроса resortId и resortName равны null.
Подробная погода
WeatherDetailsResponse используется для расширенного экрана погоды.
type WeatherDetailsResponse = {
resortId: UUID | null;
resortName: string | null;
latitude: number;
longitude: number;
updatedAt: ISODateTime;
current: WeatherCurrent | null;
wind: WeatherWind | null;
snow: WeatherSnow | null;
visibility: WeatherVisibility | null;
alpineDataStatus: WeatherDataStatus;
sun: WeatherSun | null;
hourly: WeatherHourly[];
daily: WeatherDaily[];
};
Массивы hourly и daily возвращаются как [], если данных нет.
Нормализованная модель погоды
type WeatherCurrent = {
temperature: number | null;
feelsLike: number | null;
minTemperature: number | null;
maxTemperature: number | null;
condition: WeatherCondition;
};
type WeatherWind = {
speedMetersPerSecond: number | null;
gustMetersPerSecond: number | null;
directionDegrees: number | null;
direction: string | null;
};
type WeatherSnow = {
last24HoursCm: number | null;
forecast24HoursCm: number | null;
condition: SnowCondition;
dataStatus: WeatherDataStatus;
};
type WeatherVisibility = {
meters: number | null;
};
type WeatherSun = {
sunrise: ISODateTime | null;
sunset: ISODateTime | null;
};
type WeatherHourly = {
datetime: ISODateTime | null;
temperature: number | null;
feelsLike: number | null;
humidity: number | null;
uv: number | null;
freezingLevelMeters: number | null;
windSpeedMetersPerSecond: number | null;
windGustMetersPerSecond: number | null;
windDirectionDegrees: number | null;
windDirection: string | null;
condition: WeatherCondition;
snowCm: number | null;
rainMm: number | null;
};
type WeatherDaily = {
date: string; // YYYY-MM-DD
minTemperature: number | null;
maxTemperature: number | null;
condition: WeatherCondition;
snowCm: number | null;
};
Статус доступности данных
type WeatherDataStatus =
| "AVAILABLE"
| "NOT_RECEIVED"
| "NOT_REQUESTED";
Смысл значений:
| Статус | Значение для frontend |
|---|---|
AVAILABLE | Backend получил соответствующий набор данных. Отдельное числовое поле при этом всё ещё может быть nullable, если конкретное значение источник не предоставил. |
NOT_RECEIVED | Backend ожидал эти данные для данного сценария, но актуального значения/cache нет. |
NOT_REQUESTED | Этот набор данных сознательно не запрашивается для данного сценария. Это штатное состояние, а не ошибка. |
WeatherSnow.dataStatus описывает доступность snow-summary (last24HoursCm, forecast24HoursCm, данные для классификации снежного состояния).
alpineDataStatus описывает доступность дополнительного alpine/hourly-обогащения, прежде всего freezingLevelMeters и snowCm.
Текущая логика различается по типу запроса:
weather по resortId
snow.dataStatus = AVAILABLE / NOT_RECEIVED
alpineDataStatus = AVAILABLE / NOT_RECEIVED
weather по произвольным lat/lng
snow.dataStatus = NOT_REQUESTED
alpineDataStatus = NOT_REQUESTED
Frontend не должен трактовать NOT_REQUESTED как ошибку погодного endpoint-а.
WeatherCondition
CLEAR
PARTLY_CLOUDY
CLOUDY
FOG
LIGHT_RAIN
RAIN
HEAVY_RAIN
LIGHT_SNOW
SNOW
HEAVY_SNOW
SLEET
THUNDERSTORM
UNKNOWN
UNKNOWN означает, что backend не смог достоверно нормализовать состояние для конкретной точки прогноза. На дальнем горизонте это допустимо, если внешний прогноз не содержит достаточного summary.
SnowCondition
NO_SNOW
UNKNOWN
FRESH
PACKED
POWDER
ICE
WET
SLUSH
ARTIFICIAL
NO_SNOW означает, что snow/alpine-источник доступен и снежный покров для точки отсутствует. Это отличается от UNKNOWN: UNKNOWN означает, что состояние снега определить не удалось.
Семантика null и 0
Для числовых погодных значений null и 0 имеют разный смысл.
0 = значение известно и равно нулю
null = конкретное значение не получено / не предоставлено источником
Примеры:
last24HoursCm = 0+dataStatus = AVAILABLE— данные получены, за последние 24 часа нового снега не было;last24HoursCm = null+dataStatus = NOT_RECEIVED— snow-data ожидались, но backend их не получил;last24HoursCm = null+dataStatus = NOT_REQUESTED— для данного запроса snow-data сознательно не запрашивались;gustMetersPerSecond = null— провайдер не предоставил значение порывов; это не означает, что порывов точно нет;rainMm = 0— данные по периоду получены и дождевых осадков нет;rainMm = null— для этой точки/периода количество дождя не получено;snowCm = 0— snow-прогноз на соответствующий час/день получен и снег не ожидается;snowCm = null— snow-прогноз для этой точки временного горизонта отсутствует.
Frontend не должен заменять nullable weather-поля на 0 до проверки их смысла.
Курортное alpine/snow-обогащение
Для weather-запросов по resortId backend использует серверный cache дополнительной alpine/snow-информации. Внешний alpine/snow-запрос не выполняется синхронно в пользовательском request path: cache обновляется сервером отдельно.
Практические следствия для frontend:
- курортный
nearbyполучаетlast24HoursCm,forecast24HoursCm,SnowConditionи статусы из cache; - курортный
detailsдополнительно обогащает ближайший почасовой прогнозfreezingLevelMetersиsnowCm; - текущий дополнительный почасовой cache ориентирован на ближайший прогноз (порядка нескольких суток), поэтому на дальнем горизонте
freezingLevelMetersиsnowCmмогут снова статьnullдаже приalpineDataStatus = AVAILABLE; daily.snowCmрассчитывается из доступного почасового snow-прогноза; для дней вне покрытого горизонта может бытьnull;- координатные weather-методы этот курортный cache не используют.
alpineDataStatus = AVAILABLE означает наличие alpine-набора в целом, а не гарантию заполненности каждого часа на всём дальнем горизонте.
Правила для frontend
- Не использовать provider-specific коды и названия внешних погодных API.
conditionиsnow.conditionявляются нормализованными enum «Каталки».- Скорость ветра во frontend-контракте передаётся в метрах в секунду.
direction— короткое направление ветра (N,NW,SSWи т. п.); локализованный текст (С,С-З) строит frontend.gustMetersPerSecondявляется nullable;nullозначает отсутствие полученного значения, а не нулевой порыв.uvна дальнем горизонте может бытьnull, если внешний прогноз перестаёт предоставлять UV для разреженных точек.freezingLevelMeters,snowCm,rainMmмогут бытьnullна точках, для которых соответствующие данные не получены.visibility.metersможет быть рассчитан backend-ом из доступных погодных факторов и не обязан совпадать с отдельным provider-specific visibility-полем.sunrise/sunsetмогут бытьnull, если данные солнца не получены.- Отсутствие отдельной возможности внешнего источника не считается ошибкой всего weather endpoint-а.
updatedAtиспользуется для отображения времени последнего обновления основного прогноза, в том числе в offline/Live-сценариях.- Фон и иконки погоды следует определять по
condition, а не по имени/иконке внешнего провайдера.
11. День без каталки — DayOffPlanPublicController
Два разных источника рекомендаций
| Сценарий | Endpoint | JWT | Результат |
|---|---|---|---|
| Блок на главной странице | GET /api/v1/places?group=DAY_OFF | Нет | Редакционный список активных мест PlaceCardResponse[] |
| Персональный Day-Off Plan preview | POST .../day-off-plans/preview | Нет | Предварительный план, рассчитанный по дате, времени, ответам, предпочтениям пользователя и погодному контексту курорта |
| Персональный Day-Off Plan generate | POST .../day-off-plans/generate | Да | Сохранённый план текущего пользователя |
Эти источники не являются взаимозаменяемыми. group=DAY_OFF используется для публичного контента главной, а DayOffPlan — только в авторизованном пользовательском сценарии.
Методы
| Метод | URL | JWT | Сохранение |
|---|---|---|---|
POST | /api/v1/resorts/{resortId}/day-off-plans/preview | Нет | Нет |
POST | /api/v1/resorts/by-slug/{resortSlug}/day-off-plans/preview | Нет | Нет |
POST | /api/v1/resorts/{resortId}/day-off-plans/generate | Да | Да |
POST | /api/v1/resorts/by-slug/{resortSlug}/day-off-plans/generate | Да | Да |
Тело запроса
type GenerateDayOffPlanRequest = {
date: string; // YYYY-MM-DD, обязательно
timeOfDay: "DAY" | "EVENING"; // обязательно
regenerationSeed?: number | null;
answers?: Record<string, string[]>;
preferences?: Record<string, unknown>;
context?: Record<string, unknown>;
};
Минимальный JSON:
{
"date": "2026-08-05",
"timeOfDay": "DAY"
}
Полный безопасный шаблон:
{
"date": "2026-08-05",
"timeOfDay": "DAY",
"regenerationSeed": 0,
"answers": {},
"preferences": {},
"context": {}
}
При нажатии «Создать план заново» фронту следует увеличить regenerationSeed.
Учёт погоды
При генерации персонального Day-Off Plan backend дополнительно учитывает погодный контекст курорта.
- при неблагоприятной погоде повышается приоритет
indoor-активностей и снижается приоритетoutdoor-активностей; - плохая видимость и сильный ветер могут дополнительно снижать приоритет outdoor-активностей;
- при благоприятной погоде outdoor-активности могут получить дополнительный приоритет;
- если погодные данные временно недоступны, генерация продолжается без погодной корректировки.
Frontend не передаёт погоду в GenerateDayOffPlanRequest и не применяет погодные коэффициенты самостоятельно: backend получает и нормализует погодные данные по координатам курорта. Конкретные веса погодного scoring являются внутренней реализацией backend и не входят в public API-контракт.
PowerShell: preview без JWT
$body = @{
date = "2026-08-05"
timeOfDay = "DAY"
regenerationSeed = 0
answers = @{}
preferences = @{}
context = @{}
} | ConvertTo-Json -Depth 10
curl.exe -sS --fail-with-body `
-X POST "https://api.katalka.ski/api/v1/resorts/by-slug/sheregesh/day-off-plans/preview" `
-H "Content-Type: application/json; charset=utf-8" `
--data-raw $body
PowerShell: generate с JWT
curl.exe -sS --fail-with-body `
-X POST "https://api.katalka.ski/api/v1/resorts/by-slug/sheregesh/day-off-plans/generate" `
-H "Authorization: Bearer $TOKEN" `
-H "Content-Type: application/json; charset=utf-8" `
--data-raw $body
Ответ
type DayOffPlanGenerateResponse = {
dailyPlanId: UUID | null; // null для preview
userId: UUID | null; // null для preview
resortId: UUID;
planDate: string;
timeOfDay: "DAY" | "EVENING";
source: "RULE_BASED" | "USER_CREATED" | "ADMIN_CREATED" | "TEMPLATE";
generatedAt: ISODateTime;
saved: boolean;
effectivePreferences: Record<string, unknown>;
schedule: DayOffPlanScheduleBlockResponse[];
};
type DayOffPlanScheduleBlockResponse = {
blockType: "MORNING" | "LUNCH" | "AFTERNOON" | "EVENING" | "BACKUP";
timeLabel: string;
sortOrder: number;
priority: number;
activityType: "PLACE" | "EVENT" | "REST" | "WALK" | "FOOD" |
"SPA" | "VIEWPOINT" | "SHOPPING" | "BACKUP";
title: string;
subtitle: string | null;
imageUrl: string | null;
category: string | null;
targetType: string | null;
targetId: UUID | null;
reasonCodes: string[];
facts: Record<string, unknown>;
};
Расписание упорядочено по sortOrder ASC, затем priority DESC. Основные позиции: 10 — утро, 20 — обед, 30 — после обеда, 40 — вечер.
12. Карта — ResortMapPublicController
| Метод | URL | Ответ |
|---|---|---|
GET | /api/v1/resorts/map | GlobalResortMapResponse |
GET | /api/v1/resorts/{resortId}/map-package | ResortMapPackageResponse |
GET | /api/v1/resorts/{resortId}/map/geojson | GeoJsonFeatureCollectionResponse |
GET | /api/v1/resorts/{resortId}/map/layers/{layerCode}/geojson | GeoJsonFeatureCollectionResponse |
Коды слоёв:
TRAILS, LIFTS, PLACES, CAMERAS, RENTAL_POINTS
curl.exe -sS "https://api.katalka.ski/api/v1/resorts/map"
curl.exe -sS "https://api.katalka.ski/api/v1/resorts/RESORT_UUID/map/layers/TRAILS/geojson"
type GlobalResortMapResponse = {
updatedAt: ISODateTime;
resorts: Array<{
id: UUID;
slug: string;
name: string;
latitude: number;
longitude: number;
partner: boolean;
active: boolean;
}>;
};
type ResortMapPackageResponse = {
resortId: UUID;
resortSlug: string;
resortName: string;
updatedAt: ISODateTime;
viewport: {
centerLatitude: number;
centerLongitude: number;
minZoom: number | null;
maxZoom: number | null;
defaultZoom: number | null;
bounds: number[]; // [west, south, east, north]
};
layers: Array<{
code: string;
title: string;
type: string;
geoJsonUrl: string;
visibleByDefault: boolean;
sortOrder: number;
style: Record<string, unknown>;
}>;
offline: Record<string, unknown>;
style: Record<string, unknown>;
};
type GeoJsonFeatureCollectionResponse = {
type: "FeatureCollection";
features: Array<{
type: "Feature";
id: string;
geometry: Record<string, unknown>;
properties: Record<string, unknown>;
}>;
};
13. Media-файлы — MediaPublicFileController
| Метод | URL | Ответ |
|---|---|---|
GET | /media/{mediaPath} | Бинарный файл с фактическим Content-Type |
Backend выставляет публичный cache на 30 дней и Content-Length.
Фронту предпочтительно использовать готовое поле url из MediaAssetResponse, не собирать путь вручную.
Временная совместимость существует только для WEBP:
- если
/media/medium/{storageKey}или/media/thumb/{storageKey}отсутствует; - и файл имеет расширение
.webp; - backend попробует отдать оригинальный
/media/{storageKey}.
Для отсутствующих JPG/PNG/GIF-вариантов fallback не выполняется — будет 404.
14. Общий media DTO
type UUID = string;
type ISODateTime = string;
type MediaAssetResponse = {
id: UUID;
type: "IMAGE" | "VIDEO" | "DOCUMENT" | "STREAM_PREVIEW";
url: string;
storageKey: string | null;
variants: MediaImageVariantResponse[];
mimeType: string | null;
sizeBytes: number | null;
width: number | null;
height: number | null;
durationSeconds: number | null;
title: string | null;
altText: string | null;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
type MediaImageVariantResponse = {
variant: "ORIGINAL" | "MEDIUM" | "THUMB" | string;
format: "WEBP" | string;
storageKey: string;
url: string;
width: number | null;
height: number | null;
sizeBytes: number | null;
};
Для карточек и списков фронту предпочтительно брать:
image.variants[variant = "MEDIUM"].url— обычные карточки и крупные превью;image.variants[variant = "THUMB"].url— маленькие горизонтальные плитки;image.url— fallback на исходный media URL;imageUrl— совместимое строковое поле, уже указывает на предпочтительный medium URL, если backend может его построить.
15. Важные замечания перед подключением фронта
16.1. Требования к SecurityConfig
Актуальный SecurityConfig должен:
- разрешать без JWT все перечисленные в документе
GET-маршруты Public-контроллеров, включая/api/v1/weather/**и/api/v1/resorts/*/weather/**; - разрешать без JWT
GET /api/v1/auth/avatars; - разрешать без JWT
POST /api/v1/resorts/*/day-off-plans/previewиPOST /api/v1/resorts/by-slug/*/day-off-plans/preview; - требовать JWT для обоих
POST .../day-off-plans/generate; - оставлять остальные endpoint-ы под правилом
.anyRequest().authenticated().
Если preview отвечает 401 без JWT или новые banner endpoint-ы отвечают 401, на сервере используется устаревшая конфигурация безопасности.
16.2. Глобальный список подборок фактически не ограничен resort IS NULL
GET /api/v1/collections и /collections/with-items передают в repository resortId = null, что отключает фильтрацию по курорту. Поэтому без дополнительных фильтров они могут вернуть и глобальные, и привязанные к курорту подборки.
Для строго локальной выдачи используйте /api/v1/resorts/{resortId}/collections.... Для глобальной выдачи сейчас обязательно задавайте подходящий placement, например placement=GLOBAL, если данные размечены этим значением.
16.3. Сортировка курортов
Несмотря на наличие sortOrder, GET /api/v1/resorts в текущем коде сортирует список по name ASC. Если главная должна соблюдать редакционный порядок, backend-контракт нужно отдельно переключить на sortOrder ASC.
16. Экран курорта по frontend-схеме
Схема экрана: project_sources/10-katalka_struct.jpg.
Главный экран курорта строится вокруг ResortResponse, а дополнительные блоки догружаются после получения resortId.
16.1. Первый запрос
GET /api/v1/resorts/by-slug/{slug}
Ответ ResortResponse закрывает верхнюю часть экрана:
| Блок UI | Поля API |
|---|---|
| Hero/обложка курорта | image, imageUrl, gallery |
| Название, регион, краткое описание | name, region, shortDescription, description |
| Теги под названием | metaTags, previewTags |
| Особенности | features[] |
| Характеристики | characteristics |
| Лучший сезон | seasonInfo |
| Стоимость отдыха | priceInfo |
| Где находится | latitude, longitude, address, region |
| Как добраться | routeInfo |
| Подробнее/внешние ссылки | links |
Для картинок в hero и stories-экранах фронту лучше использовать image.variants и features[].coverMedia.variants.
16.2. Истории особенностей
Горизонтальный блок «Особенности» берётся из ResortResponse.features.
type ResortFeatureResponse = {
previewMedia: MediaAssetResponse | null; // маленькая плитка
coverMedia: MediaAssetResponse | null; // полноэкранная история
coverPositionX: number;
coverPositionY: number;
};
Рекомендуемое использование:
| Экран | Media |
|---|---|
| Маленькая плитка особенности | `previewMedia.variants[variant = "THUMB" |
| Полноэкранная история | `coverMedia.variants[variant = "ORIGINAL" |
| Позиция изображения | coverPositionX, coverPositionY |
Если coverMedia = null, историю можно не открывать или показывать fallback из previewMedia.
16.3. Сезон и стоимость
Переходы из блоков «Лучший сезон» и «Стоимость отдыха» не требуют отдельных endpoint-ов:
| Экран | Источник |
|---|---|
| «Сезон в Шерегеше» | ResortResponse.seasonInfo |
| «Стоимость отдыха» | ResortResponse.priceInfo |
Поля пока гибкие (Record<string, unknown>), поэтому фронту нужно читать согласованную структуру из content export и безопасно пропускать неизвестные ключи.
16.4. Места, еда, день без каталки и инсталокации
После получения resortId фронт может параллельно загрузить горизонтальные блоки мест:
GET /api/v1/resorts/{resortId}/places?type=RESTAURANT
GET /api/v1/resorts/{resortId}/places?group=DAY_OFF
GET /api/v1/resorts/{resortId}/places?type=PHOTO_SPOT
GET /api/v1/resorts/{resortId}/places/by-type
Для карточек используется PlaceCardResponse.image:
| Блок UI | Рекомендуемый фильтр | Картинка |
|---|---|---|
| «Где поесть?» | type=RESTAURANT, дополнительно CAFE/BAR при необходимости | `image.variants[variant = "THUMB" |
| «День без каталки» внутри курорта | group=DAY_OFF | `image.variants[variant = "THUMB" |
| «Инсталокации» | type=PHOTO_SPOT или согласованный groupCode | `image.variants[variant = "THUMB" |
Для открытия полной карточки места:
GET /api/v1/resorts/{resortId}/places/by-slug/{slug}
GET /api/v1/places/{placeId}
Детальная карточка места использует PlaceDetailsResponse.coverMedia, media[], latitude, longitude, address, contacts, workingHours, priceInfo.
16.5. Карта
На экране карты курорта нужны два источника:
GET /api/v1/resorts/{resortId}/map-package
GET /api/v1/resorts/{resortId}/places/by-type
map-package даёт viewport и GeoJSON-слои трасс/подъёмников/точек. places/by-type удобно использовать для нижних карточек и группировки маркеров по типам мест.
Открытие карточки маркера:
GET /api/v1/places/{placeId}
16.6. Сообщить о неточности
В схеме есть пользовательский экран «Сообщить о неточности». В текущем public-контракте production endpoint для него ещё не зафиксирован: в backend виден только тестовый /api/v1/test/inaccuracy-reports.
Для мобильного фронта нужен отдельный публичный endpoint, например:
POST /api/v1/inaccuracy-reports
До появления production endpoint-а фронту не следует использовать /api/v1/test/inaccuracy-reports.
16.7. Day-Off Plan
Персональный план можно показать из экрана курорта:
POST /api/v1/resorts/{resortId}/day-off-plans/preview
POST /api/v1/resorts/{resortId}/day-off-plans/generate
preview публичный и ничего не сохраняет. generate требует JWT и сохраняет план пользователю.
Публичный блок «День без каталки» на главной странице загружается отдельно:
GET /api/v1/places?group=DAY_OFF
16.8. Рекомендуемая параллельная загрузка
- Сначала
GET /api/v1/resorts/by-slug/{slug}. - После получения
resortIdпараллельно загрузитьweather/nearby,places,events,cameras,collections,map-package. - Тяжёлые детальные карточки мест открывать лениво через
GET /api/v1/places/{placeId}илиby-slug. - Для всех карточек сначала использовать
image.variants, затем fallback наimageUrl, затем fallback наimage.url.
17. Закладки — FavoritePublicController
Все методы закладок работают от текущего пользователя из JWT. userId в URL или JSON frontend не передаёт.
Базовый путь:
/api/v1/favorites
Endpoint-ы
| Метод | URL | JWT | Ответ | Назначение |
|---|---|---|---|---|
GET | /api/v1/favorites | Да | FavoriteResponse[] | Все закладки текущего пользователя |
GET | /api/v1/favorites?type={targetType} | Да | FavoriteResponse[] | Закладки только выбранного типа |
POST | /api/v1/favorites | Да | FavoriteResponse | Добавить закладку |
DELETE | /api/v1/favorites/{targetType}/{targetId} | Да | 204 No Content | Удалить закладку |
GET | /api/v1/favorites/{targetType}/{targetId}/exists | Да | FavoriteExistsResponse | Проверить наличие закладки |
POST и DELETE идемпотентны: повторное добавление возвращает существующую закладку, повторное удаление не считается ошибкой.
FavoriteTargetType
RESORT
PLACE
RENTAL_POINT
EVENT
ARTICLE
COLLECTION
CAMERA
TRAIL
Добавление закладки
POST /api/v1/favorites
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"targetType": "PLACE",
"targetId": "7e17f688-7bcb-4f25-b63d-f918d06958f3"
}
type FavoriteResponse = {
id: UUID;
userId: UUID;
targetType: FavoriteTargetType;
targetId: UUID;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
Backend проверяет, что указанная целевая сущность существует. Frontend не должен использовать произвольный targetType, которого нет в enum.
Проверка закладки
GET /api/v1/favorites/PLACE/{placeId}/exists
Authorization: Bearer <accessToken>
{
"favorite": true
}
type FavoriteExistsResponse = {
favorite: boolean;
};
Удаление
DELETE /api/v1/favorites/PLACE/{placeId}
Authorization: Bearer <accessToken>
Успешный ответ: 204 No Content. Для удаления используется пара targetType + targetId; frontend не обязан хранить favorite.id.
Использование на экране закладок
FavoriteResponse является лёгкой ссылкой на целевую сущность и не содержит её название, картинку или описание. Для отображения детальной карточки frontend использует соответствующий public API сущности.
18. Авторизация
Мобильное приложение использует собственную авторизацию backend «Каталки».
Поддерживаемые способы входа:
EMAIL
PHONE
GOOGLE
APPLE
VK
YANDEX
После успешной регистрации или авторизации backend возвращает две сущности:
accessToken— короткоживущий JWT для доступа к защищённым API;refreshToken— долгоживущий токен сессии, используемый только для получения новой пары токенов.
Для защищённых методов передаётся только accessToken:
Authorization: Bearer <accessToken>
refreshToken нельзя передавать вместо JWT в обычные API-запросы.
Что изменилось в 1.1.4
- Добавлен публичный
WeatherPublicControllerс короткой и подробной погодой по координатам и по курорту. - Weather frontend-контракт отделён от конкретного внешнего провайдера.
- В
POST /api/v1/auth/change-passwordудалено полеcurrentPassword; передаётся толькоnewPassword. - В
POST /api/v1/auth/change-emailудалено полеcurrentPassword; передаётся толькоnewEmail. change-nameиchange-phoneтакже не требуют старого пароля.- Multipart-контракт
change-avatarпо-прежнему не опубликован; для frontend добавлены каталогGET /api/v1/auth/avatarsи выборPOST /api/v1/auth/select-avatar.
Что изменилось в 1.1.3
До версии 1.1.3 frontend работал только с JWT access token:
{
"tokenType": "Bearer",
"accessToken": "...",
"expiresInSeconds": 1800,
"user": {}
}
После истечения JWT требовалась повторная авторизация пользователя.
Начиная с 1.1.3, успешная авторизация возвращает также rotating refresh token:
{
"tokenType": "Bearer",
"accessToken": "...",
"refreshToken": "...",
"expiresInSeconds": 1800,
"refreshExpiresInSeconds": 2592000,
"user": {}
}
expiresInSeconds и refreshExpiresInSeconds являются источником истины для клиента. Не следует жёстко зашивать TTL в приложение.
Текущая конфигурация backend:
access token ≈ 30 минут
refresh token ≈ 30 дней
Rotation refresh token
Refresh token одноразовый в рамках операции обновления.
При успешном:
POST /api/v1/auth/refresh
старый refresh token становится недействительным, а backend возвращает:
новый accessToken
+
новый refreshToken
Frontend обязан атомарно заменить оба токена сохранёнными значениями из ответа.
Нельзя продолжать использовать старый refresh token после успешного refresh.
Общие public/protected правила
Без JWT доступны:
- public
GET-методы каталога; - регистрация и login;
- запрос и проверка Phone OTP;
POST /api/v1/auth/refresh;POST .../day-off-plans/preview.
JWT требуется для:
/api/v1/auth/me;- изменения пользовательских данных;
- сохранения персонального Day-Off Plan через
generate; - всех методов
/api/v1/favorites/**; - других пользовательских API, работающих от текущего аккаунта.
userId для защищённых операций backend получает из sub JWT. Передавать идентификатор текущего пользователя вручную вместо JWT не нужно.
18.1. Авторизация — AuthController
Базовый путь:
/api/v1/auth
Endpoint-ы
| Метод | URL | JWT | Назначение |
|---|---|---|---|
POST | /api/v1/auth/register/email | Нет | Регистрация по email |
POST | /api/v1/auth/login/email | Нет | Вход по email |
POST | /api/v1/auth/login/google | Нет | Google Sign-In |
POST | /api/v1/auth/login/apple | Нет | Apple Sign-In |
POST | /api/v1/auth/login/vk | Нет | VK ID |
POST | /api/v1/auth/login/yandex | Нет | Yandex ID |
POST | /api/v1/auth/phone/otp/request | Нет | Запрос SMS-кода |
POST | /api/v1/auth/phone/otp/verify | Нет | Проверка SMS-кода и вход |
POST | /api/v1/auth/refresh | Нет | Обновление access/refresh token |
GET | /api/v1/auth/avatars | Нет | Каталог предустановленных аватаров |
GET | /api/v1/auth/me | Да | Текущий пользователь |
POST | /api/v1/auth/change-name | Да | Изменение имени |
POST | /api/v1/auth/change-phone | Да | Изменение телефона |
POST | /api/v1/auth/change-email | Да | Изменение email |
POST | /api/v1/auth/change-password | Да | Изменение пароля |
POST | /api/v1/auth/select-avatar | Да | Выбор предустановленного аватара |
POST | /api/v1/auth/logout | — | Завершение refresh-сессии |
AuthTokenResponse
Одинаковый формат используется после регистрации, login и refresh:
type AuthTokenResponse = {
tokenType: "Bearer";
accessToken: string;
refreshToken: string;
expiresInSeconds: number;
refreshExpiresInSeconds: number;
user: CurrentUserResponse;
};
type CurrentUserResponse = {
id: UUID;
name: string | null;
avatarUrl: string | null;
preferences: Record<string, unknown>;
blocked: boolean;
authProvider:
| "EMAIL"
| "PHONE"
| "GOOGLE"
| "APPLE"
| "VK"
| "YANDEX";
email: string | null;
phone: string | null;
createdAt: ISODateTime;
updatedAt: ISODateTime;
};
Email — регистрация
POST /api/v1/auth/register/email
Content-Type: application/json
{
"email": "user@example.com",
"password": "StrongPassword123",
"name": "Roman"
}
Ответ:
{
"tokenType": "Bearer",
"accessToken": "jwt-access-token",
"refreshToken": "refresh-token",
"expiresInSeconds": 1800,
"refreshExpiresInSeconds": 2592000,
"user": {
"id": "8a5f5f4d-8d27-4a7e-a4c1-8a0e8b4cbb2f",
"name": "Roman",
"avatarUrl": null,
"preferences": {},
"blocked": false,
"authProvider": "EMAIL",
"email": "user@example.com",
"phone": null,
"createdAt": "2026-08-13T08:00:00+03:00",
"updatedAt": "2026-08-13T08:00:00+03:00"
}
}
Email — login
POST /api/v1/auth/login/email
Content-Type: application/json
{
"email": "user@example.com",
"password": "StrongPassword123"
}
Ответ — AuthTokenResponse.
Frontend получает Google ID token средствами Google Sign-In и передаёт его backend:
POST /api/v1/auth/login/google
{
"idToken": "google-id-token"
}
Внешний Google token используется только для подтверждения личности. Для последующих запросов к API «Каталки» используется accessToken, возвращённый backend.
Apple
POST /api/v1/auth/login/apple
{
"identityToken": "apple-identity-token",
"firstName": "Roman",
"lastName": "Kuzmin",
"nonce": "optional-nonce"
}
firstName и lastName особенно важны при первом Apple Sign-In: Apple может не возвращать их повторно.
VK ID
POST /api/v1/auth/login/vk
Frontend проходит VK ID OAuth flow с PKCE и передаёт backend authorization code и параметры обмена.
Backend самостоятельно обменивает authorization code на данные VK и после успешной проверки возвращает собственный AuthTokenResponse.
VK access token не является токеном доступа к API «Каталки».
Yandex ID
POST /api/v1/auth/login/yandex
Frontend получает authorization code Yandex ID и передаёт его backend.
Backend выполняет обмен с Yandex и возвращает собственный AuthTokenResponse.
Yandex OAuth token не используется в:
Authorization: Bearer ...
для API «Каталки».
Phone OTP
Запрос кода:
POST /api/v1/auth/phone/otp/request
Проверка кода:
POST /api/v1/auth/phone/otp/verify
Успешная проверка OTP выполняет login или регистрацию и возвращает полный AuthTokenResponse, включая accessToken и refreshToken.
Предустановленные аватары
Список аватаров загружается с backend. Frontend не должен встраивать каталог аватаров в приложение как фиксированный набор: backend может менять изображения, состав и порядок без выпуска новой версии мобильного приложения.
Получение каталога публичное и не требует JWT:
GET /api/v1/auth/avatars
Accept: application/json
Ответ:
type ProfileAvatarListResponse = {
items: ProfileAvatarResponse[];
};
type ProfileAvatarResponse = {
code: string; // например AVATAR_1
url: string; // media URL backend
sortOrder: number;
};
Пример:
{
"items": [
{
"code": "AVATAR_1",
"url": "/media/avatars/avatar-1.webp",
"sortOrder": 10
},
{
"code": "AVATAR_2",
"url": "/media/avatars/avatar-2.webp",
"sortOrder": 20
}
]
}
Frontend отображает элементы в порядке sortOrder ASC и при сохранении отправляет только code, а не произвольный URL.
Выбор аватара требует JWT:
POST /api/v1/auth/select-avatar
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"avatarCode": "AVATAR_2"
}
Успешный ответ — CurrentUserResponse. Поле avatarUrl содержит URL выбранного системного аватара. Frontend должен заменить локальные данные пользователя значениями из ответа.
Если передан неизвестный avatarCode, backend возвращает ошибку 400.
Существующий multipart endpoint загрузки пользовательского изображения POST /api/v1/auth/change-avatar остаётся внутренней/legacy-возможностью и в frontend-контракт 1.1.9 не входит.
Изменение имени
POST /api/v1/auth/change-name
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"name": "Roman"
}
Старый пароль не передаётся.
Изменение телефона
POST /api/v1/auth/change-phone
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"phone": "+79991234567"
}
Старый пароль не передаётся.
Изменение email
Доступно для аккаунтов с authProvider = "EMAIL".
POST /api/v1/auth/change-email
Authorization: Bearer <accessToken>
Content-Type: application/json
Начиная с версии 1.1.4, текущий пароль в запросе не передаётся. Авторизация текущей операции выполняется по действующему access JWT.
{
"newEmail": "new-user@example.com"
}
После успешной смены email backend возвращает AuthTokenResponse. Frontend должен заменить локальные auth-данные значениями из ответа.
Изменение пароля
Доступно для аккаунтов с authProvider = "EMAIL".
POST /api/v1/auth/change-password
Authorization: Bearer <accessToken>
Content-Type: application/json
Начиная с версии 1.1.4, currentPassword удалён из контракта. Передаётся только новый пароль:
{
"newPassword": "NewStrongPassword123"
}
Ограничения newPassword:
минимум 8 символов
максимум 72 символа
Успешный ответ:
{
"status": "ok",
"message": "Password changed"
}
Frontend не должен отправлять currentPassword в change-password или change-email: это поле больше не является частью актуального контракта.
Refresh token
Запрос
POST /api/v1/auth/refresh
Accept: application/json
Content-Type: application/json; charset=utf-8
JWT в Authorization для этого метода не требуется: access token к моменту refresh может быть уже просрочен.
Body:
{
"refreshToken": "current-refresh-token"
}
Успешный ответ
{
"tokenType": "Bearer",
"accessToken": "new-access-token",
"refreshToken": "new-refresh-token",
"expiresInSeconds": 1800,
"refreshExpiresInSeconds": 2592000,
"user": {
"id": "8a5f5f4d-8d27-4a7e-a4c1-8a0e8b4cbb2f",
"authProvider": "EMAIL",
"email": "user@example.com"
}
}
После этого:
OLD accessToken -> больше не использовать
OLD refreshToken -> больше не использовать
NEW accessToken -> использовать для API
NEW refreshToken -> сохранить для следующего refresh
Рекомендуемый алгоритм frontend
После login/register
- Получить
AuthTokenResponse. - Сохранить
accessToken. - Сохранить
refreshToken. - Сохранить/обновить данные
user. - Использовать
accessTokenв защищённых запросах.
Перед защищённым запросом
Authorization: Bearer <accessToken>
При истечении access token
Рекомендуемый сценарий:
API request
|
+-- 2xx ----------------------> вернуть ответ
|
+-- 401
|
+--> POST /api/v1/auth/refresh
|
+-- success
| |
| +--> сохранить НОВУЮ пару токенов
| +--> повторить исходный запрос ОДИН раз
|
+-- error
|
+--> очистить локальную auth-сессию
+--> показать экран авторизации
Frontend не должен создавать бесконечный цикл:
401 -> refresh -> retry -> 401 -> refresh -> ...
Исходный запрос после успешного refresh следует автоматически повторять не более одного раза.
Параллельные 401
Если несколько API-запросов одновременно получили 401, приложение не должно выполнять несколько refresh-запросов одним refresh token.
Нужен один общий refresh lock / mutex:
request A -> 401 ┐
request B -> 401 ├──> один POST /auth/refresh
request C -> 401 ┘
|
+--> новая пара токенов
|
+--> повтор A
+--> повтор B
+--> повтор C
Это особенно важно из-за rotation: первый успешный refresh инвалидирует предыдущий refresh token.
Хранение токенов на мобильном устройстве
refreshToken следует считать credential уровня пароля.
Не хранить его в:
Redux persisted state
AsyncStorage в открытом виде
логах
analytics events
crash reports
URL/query parameters
Для production-клиента рекомендуется защищённое системное хранилище:
iOS -> Keychain
Android -> Keystore / encrypted secure storage
accessToken также не следует логировать.
Logout
Logout завершает refresh-сессию пользователя.
Frontend должен передать текущий refresh token в logout-запрос согласно актуальному DTO backend, а после успешного ответа удалить локально:
accessToken
refreshToken
user/session state
После logout уже выданный access JWT может оставаться криптографически валидным до конца своего короткого TTL, но получить через отозванный refresh token новый access token больше нельзя.
Ошибки авторизации
401 Unauthorized
Для обычного защищённого API:
- access token отсутствует;
- access token истёк;
- access token некорректен.
Если имеется refresh token, frontend может выполнить один refresh flow.
Для /api/v1/auth/refresh ошибка означает, что текущую сессию продолжить нельзя, например refresh token:
- неизвестен backend;
- уже был использован при rotation;
- отозван;
- истёк.
В этом случае frontend должен очистить локальную auth-сессию и запросить повторную авторизацию.
400 Bad Request
Некорректное тело login/register/refresh-запроса или нарушение validation.
403 Forbidden
Пользователь аутентифицирован, но операция запрещена.
Совместимость с предыдущим frontend
Главное изменение контракта версии 1.1.3:
{
"tokenType": "Bearer",
"accessToken": "...",
+ "refreshToken": "...",
"expiresInSeconds": 1800,
+ "refreshExpiresInSeconds": 2592000,
"user": {}
}
Сам способ использования access JWT не изменился:
Authorization: Bearer <accessToken>
Новые обязательные действия frontend:
- сохранять
refreshToken; - реализовать
POST /api/v1/auth/refresh; - после refresh заменять оба токена;
- сериализовать параллельные refresh-запросы;
- при невозможности refresh завершать локальную auth-сессию.
19. Версии документа
Актуальная версия документа всегда указана в шапке. Строки ниже — только история изменений; устаревшие решения из них не применяются как текущий контракт.
| Версия | Дата | Изменения |
|---|---|---|
1.1.10 | 2026-09-02 | Weather: добавлены WeatherDataStatus (AVAILABLE, NOT_RECEIVED, NOT_REQUESTED), snow.dataStatus, alpineDataStatus, SnowCondition.NO_SNOW; уточнена семантика null и 0. Курортные weather-методы используют серверный cache snow/alpine-данных (last24HoursCm, forecast24HoursCm, ближайшие freezingLevelMeters/snowCm), координатные методы этот cache не используют. Для координат добавлен необязательный altitudeMeters. Уточнено поведение rainMm, gustMetersPerSecond, дальних uv/snow/alpine-полей. |
1.1.9 | 2026-09-02 | CollectionItemTargetResponse: добавлены latitude, longitude, altitudeMeters. Координаты берутся только из самой базовой сущности target: RESORT — координаты и высота Resort; PLACE и EVENT — собственные координаты, высота пока null; для типов без собственных координат поля остаются null. |
1.1.8 | 2026-08-31 | CollectionResponse и CollectionWithItemsResponse: добавлено поле collectionType — смысловой тип коллекции, независимый от type элементов. Значения: DAY_OFF, PHOTO_SPOTS, FOOD, RENTAL, RESORT, PLACES. Фронт больше не должен определять раздел коллекции по slug; для legacy-данных поле может вычисляться backend-ом или оставаться null, если смысл невозможно определить однозначно. |
1.1.7 | 2026-08-25 | ResortResponse: добавлены radius (радиус территории курорта в километрах) и group (id, slug, name) для отображения принадлежности курорта к активной группе; group = null для курортов без группы. |
1.1.6 | 2026-08-21 | Добавлен FavoritePublicController: JWT-закладки текущего пользователя для RESORT, PLACE, RENTAL_POINT, EVENT, ARTICLE, COLLECTION, CAMERA, TRAIL; добавление/удаление, фильтр по типу и проверка exists. userId определяется только из JWT. |
1.1.5 | 2026-08-20 | Добавлены backend-каталог предустановленных аватаров GET /api/v1/auth/avatars и выбор POST /api/v1/auth/select-avatar; multipart change-avatar остаётся вне frontend-контракта. Зафиксирован MET Norway как основной backend weather-provider без изменения public weather DTO/endpoint-ов. Day-Off Plan теперь учитывает погодный контекст курорта как внутренний modifier indoor/outdoor scoring; frontend погоду в запрос не передаёт. |
1.1.4 | 2026-08-18 | Добавлен публичный weather-контракт: nearby/details по координатам и курорту, нормализованные WeatherCondition/SnowCondition, ветер в м/с; из change-password и change-email удалён currentPassword. change-avatar в версию не включён. |
1.1.3 | 2026-08-13 | Расширен frontend-контракт авторизации: описаны все auth-провайдеры, AuthTokenResponse, rotating refresh token, POST /api/v1/auth/refresh, lifecycle токенов, обработка 401, параллельный refresh и logout. |
1.1.2 | 2026-08-06 | Актуальное правило — day-off-plans/preview публичный без JWT, generate авторизованный. |
1.1.1 | 2026-08-06 | Добавлено описание экрана курорта по frontend-схеме; зафиксированы image.variants для resort/place; day-off-plans/preview переведён в публичный сценарий без JWT. |
1.1.0 | 2026-08-05 | Историческая фиксация разделения places?group=DAY_OFF и DayOffPlan; правило про обязательный JWT для preview отменено в 1.1.1 и не является актуальным. |
1.0.0 | 2026-08-05 | Первая сводная версия по 10 Public-контроллерам. |