PUBLIC_API_FRONTEND.md · версия 1.1.10

«Каталка»: 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}

Авторизация

Форматы

Заголовки

Для обычного чтения:

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-овНазначение
ResortPublicController3Список и карточка курорта
PlacePublicController10Места, категории, случайная и редакционная карточка
EventPublicController8Глобальные и локальные события
BannerPublicController4Глобальные и курортные баннеры
CollectionPublicController6Подборки и их наполненные элементы
ArticlePublicController3Опубликованные статьи
CameraPublicController2Камеры курорта
WeatherPublicController4Короткая и подробная погода по координатам или курорту
DayOffPlanPublicController4Публичный preview и авторизованное сохранение персонального плана
ResortMapPublicController4Глобальная карта, map package и GeoJSON
MediaPublicFileController1Файлы изображений и других media
FavoritePublicController4Закладки текущего пользователя

3. Курорты — ResortPublicController

Методы

МетодURLОтветПоведение
GET/api/v1/resortsResortResponse[]Все активные курорты; текущая сортировка 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-typePlaceTypeGroupResponse[]
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-typePlaceTypeGroupResponse[]
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"

Поведение

Типы мест

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/eventsEventResponse[]Глобальные активные: resortId = null
GET/api/v1/events/upcomingEventResponse[]Глобальные, startTime >= now
GET/api/v1/events/localEventResponse[]Локальные активные всех курортов
GET/api/v1/events/local/upcomingEventResponse[]Локальные всех курортов, 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;
};

Особенности текущей реализации:

6. Баннеры — BannerPublicController

МетодURLПараметрыОтвет
GET/api/v1/bannersplacement?, slug?, limit?BannerResponse[]
GET/api/v1/resorts/{resortId}/bannersplacement?, limit?BannerResponse[]
GET/api/v1/banners/{bannerId}—BannerResponse
GET/api/v1/banners/by-slug/{slug}—BannerResponse

limit: по умолчанию 50, максимум 100; 0 и отрицательные значения заменяются на 50.

Показываются только баннеры, для которых:

Сортировка списков: 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/collectionstype?, placement?, slug?CollectionResponse[]
GET/api/v1/collections/with-itemstype?, placement?, slug?CollectionWithItemsResponse[]
GET/api/v1/resorts/{resortId}/collectionstype?, placement?CollectionResponse[]
GET/api/v1/resorts/{resortId}/collections/with-itemstype?, 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.

Выбор метода

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, а не автоматически наследуются от связанного курорта:

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}/camerasCameraResponse[]
GET/api/v1/resorts/by-slug/{resortSlug}/camerasCameraResponse[]

Возвращаются только активные камеры активного курорта, сортировка 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/nearbyWeatherNearbyResponseКороткая погода выбранного курорта
GET/api/v1/resorts/{resortId}/weather/detailsWeatherDetailsResponseПодробная погода выбранного курорта

Все четыре метода публичные и не требуют JWT.

Для запросов по координатам:

Для запросов по курорту 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
AVAILABLEBackend получил соответствующий набор данных. Отдельное числовое поле при этом всё ещё может быть nullable, если конкретное значение источник не предоставил.
NOT_RECEIVEDBackend ожидал эти данные для данного сценария, но актуального значения/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  = конкретное значение не получено / не предоставлено источником

Примеры:

Frontend не должен заменять nullable weather-поля на 0 до проверки их смысла.

Курортное alpine/snow-обогащение

Для weather-запросов по resortId backend использует серверный cache дополнительной alpine/snow-информации. Внешний alpine/snow-запрос не выполняется синхронно в пользовательском request path: cache обновляется сервером отдельно.

Практические следствия для frontend:

alpineDataStatus = AVAILABLE означает наличие alpine-набора в целом, а не гарантию заполненности каждого часа на всём дальнем горизонте.

Правила для frontend

11. День без каталки — DayOffPlanPublicController

Два разных источника рекомендаций

СценарийEndpointJWTРезультат
Блок на главной страницеGET /api/v1/places?group=DAY_OFFНетРедакционный список активных мест PlaceCardResponse[]
Персональный Day-Off Plan previewPOST .../day-off-plans/previewНетПредварительный план, рассчитанный по дате, времени, ответам, предпочтениям пользователя и погодному контексту курорта
Персональный Day-Off Plan generatePOST .../day-off-plans/generateДаСохранённый план текущего пользователя

Эти источники не являются взаимозаменяемыми. group=DAY_OFF используется для публичного контента главной, а DayOffPlan — только в авторизованном пользовательском сценарии.

Методы

МетодURLJWTСохранение
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 дополнительно учитывает погодный контекст курорта.

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/mapGlobalResortMapResponse
GET/api/v1/resorts/{resortId}/map-packageResortMapPackageResponse
GET/api/v1/resorts/{resortId}/map/geojsonGeoJsonFeatureCollectionResponse
GET/api/v1/resorts/{resortId}/map/layers/{layerCode}/geojsonGeoJsonFeatureCollectionResponse

Коды слоёв:

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:

Для отсутствующих 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;
};

Для карточек и списков фронту предпочтительно брать:

15. Важные замечания перед подключением фронта

16.1. Требования к SecurityConfig

Актуальный SecurityConfig должен:

Если 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. Рекомендуемая параллельная загрузка

  1. Сначала GET /api/v1/resorts/by-slug/{slug}.
  2. После получения resortId параллельно загрузить weather/nearby, places, events, cameras, collections, map-package.
  3. Тяжёлые детальные карточки мест открывать лениво через GET /api/v1/places/{placeId} или by-slug.
  4. Для всех карточек сначала использовать image.variants, затем fallback на imageUrl, затем fallback на image.url.

17. Закладки — FavoritePublicController

Все методы закладок работают от текущего пользователя из JWT. userId в URL или JSON frontend не передаёт.

Базовый путь:

/api/v1/favorites

Endpoint-ы

МетодURLJWTОтветНазначение
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:

Authorization: Bearer <accessToken>

refreshToken нельзя передавать вместо JWT в обычные API-запросы.

Что изменилось в 1.1.4

Что изменилось в 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 доступны:

JWT требуется для:

userId для защищённых операций backend получает из sub JWT. Передавать идентификатор текущего пользователя вручную вместо JWT не нужно.


18.1. Авторизация — AuthController

Базовый путь:

/api/v1/auth

Endpoint-ы

МетодURLJWTНазначение
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.


Google

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

  1. Получить AuthTokenResponse.
  2. Сохранить accessToken.
  3. Сохранить refreshToken.
  4. Сохранить/обновить данные user.
  5. Использовать 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:

Если имеется refresh token, frontend может выполнить один refresh flow.

Для /api/v1/auth/refresh ошибка означает, что текущую сессию продолжить нельзя, например refresh token:

В этом случае 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:

  1. сохранять refreshToken;
  2. реализовать POST /api/v1/auth/refresh;
  3. после refresh заменять оба токена;
  4. сериализовать параллельные refresh-запросы;
  5. при невозможности refresh завершать локальную auth-сессию.

19. Версии документа

Актуальная версия документа всегда указана в шапке. Строки ниже — только история изменений; устаревшие решения из них не применяются как текущий контракт.

ВерсияДатаИзменения
1.1.102026-09-02Weather: добавлены 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.92026-09-02CollectionItemTargetResponse: добавлены latitude, longitude, altitudeMeters. Координаты берутся только из самой базовой сущности target: RESORT — координаты и высота Resort; PLACE и EVENT — собственные координаты, высота пока null; для типов без собственных координат поля остаются null.
1.1.82026-08-31CollectionResponse и CollectionWithItemsResponse: добавлено поле collectionType — смысловой тип коллекции, независимый от type элементов. Значения: DAY_OFF, PHOTO_SPOTS, FOOD, RENTAL, RESORT, PLACES. Фронт больше не должен определять раздел коллекции по slug; для legacy-данных поле может вычисляться backend-ом или оставаться null, если смысл невозможно определить однозначно.
1.1.72026-08-25ResortResponse: добавлены radius (радиус территории курорта в километрах) и group (id, slug, name) для отображения принадлежности курорта к активной группе; group = null для курортов без группы.
1.1.62026-08-21Добавлен FavoritePublicController: JWT-закладки текущего пользователя для RESORT, PLACE, RENTAL_POINT, EVENT, ARTICLE, COLLECTION, CAMERA, TRAIL; добавление/удаление, фильтр по типу и проверка exists. userId определяется только из JWT.
1.1.52026-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.42026-08-18Добавлен публичный weather-контракт: nearby/details по координатам и курорту, нормализованные WeatherCondition/SnowCondition, ветер в м/с; из change-password и change-email удалён currentPassword. change-avatar в версию не включён.
1.1.32026-08-13Расширен frontend-контракт авторизации: описаны все auth-провайдеры, AuthTokenResponse, rotating refresh token, POST /api/v1/auth/refresh, lifecycle токенов, обработка 401, параллельный refresh и logout.
1.1.22026-08-06Актуальное правило — day-off-plans/preview публичный без JWT, generate авторизованный.
1.1.12026-08-06Добавлено описание экрана курорта по frontend-схеме; зафиксированы image.variants для resort/place; day-off-plans/preview переведён в публичный сценарий без JWT.
1.1.02026-08-05Историческая фиксация разделения places?group=DAY_OFF и DayOffPlan; правило про обязательный JWT для preview отменено в 1.1.1 и не является актуальным.
1.0.02026-08-05Первая сводная версия по 10 Public-контроллерам.