Files
trip-planner/docs/specification.md
2026-08-13 16:20:11 +03:00

33 KiB
Raw Blame History

Техническое задание: сервис планирования мультимодальных поездок

Версия: 0.1 (черновик для старта разработки) Язык реализации: Go Дата: 13.08.2026


1. Концепция продукта

1.1 Проблема

Существующие агрегаторы (Aviasales, Туту.ру и др.) в основном монотранспортны (только самолёты или только поезда) и ограничивают глубину поиска 12 пересадками из соображений UX и производительности. В результате пользователь не видит:

  • маршруты, комбинирующие разные виды транспорта (самолёт + поезд + автобус);
  • маршруты через альтернативные (соседние) узлы, когда основной недоступен (например, аэропорт закрыт на ремонт);
  • маршруты глубже 2 пересадок, даже если они дешевле или единственно возможны.

1.2 Отличительные особенности (УТП)

  1. Мультимодальный поиск — единый граф из самолётов, поездов, электричек, автобусов на основе данных Яндекс.Расписаний.
  2. Произвольная глубина пересадок — не ограничиваемся 12 пересадками, ищем по всему разумному пространству вариантов.
  3. Автоматическое использование соседних аэропортов/вокзалов, включая автодетект закрытия основного узла.
  4. Визуализация всего маршрута на карте — сквозная линия пути со всеми сегментами и пересадками.

1.3 Источник данных

API Яндекс.Расписаний (/stations_list, /nearest_stations, /search, /schedule, /thread). Ключевое ограничение: API не отдаёт полный дамп расписания сети, только ответы по конкретным запросам (точка А → точка Б на дату, или все рейсы через станцию). Это существенно влияет на архитектуру построения графа (см. раздел 7.2).


2. Технологический стек

Компонент Технология Обоснование
Backend Go требование заказчика
Основная БД PostgreSQL справочники, история статусов станций, конфигурация
Кэш / очереди TTL Redis нативный TTL, низкая латентность на горячих ключах
Хранение геометрии маршрутов PostGIS (опционально) или обычные lat/lon + GeoJSON на лету для карты достаточно генерировать GeoJSON без полноценного GIS
Фронтенд карты Leaflet + OpenStreetMap тайлы без vendor lock-in на платные карто-провайдеры
Планировщик задач cron внутри cmd/cron или системный cron + отдельный бинарник обновление справочников и детект статусов станций

3. Архитектура системы

                         ┌───────────────────┐
                         │   Yandex Schedules  │
                         │        API          │
                         └─────────┬───────────┘
                                   │
                     ┌─────────────▼─────────────┐
                     │     internal/yandex        │
                     │  клиент, rate limiter,     │
                     │  retry, circuit breaker    │
                     └─────────────┬─────────────┘
                                   │
        ┌──────────────────────────┼──────────────────────────┐
        │                          │                          │
┌───────▼────────┐       ┌─────────▼─────────┐      ┌─────────▼─────────┐
│  internal/cache │       │  internal/storage  │      │ internal/airports │
│  Redis (TTL)     │◄─────┤  Postgres репо      │      │ соседние аэропорты,│
│                  │       │  cities/stations/   │      │ автодетект закрытия│
└──────────────────┘       │  transfer_rules     │      └─────────┬─────────┘
                            └─────────┬─────────┘                │
                                      │                            │
                            ┌─────────▼────────────────────────────▼─────────┐
                            │              internal/routing                   │
                            │   граф, лениво расширяемый; RAPTOR-подобный      │
                            │   поиск с ограничением глубины; MCT-логика       │
                            └─────────────────────┬────────────────────────────┘
                                                   │
                                         ┌─────────▼─────────┐
                                         │     cmd/api         │
                                         │  HTTP-хендлеры,     │
                                         │  сборка GeoJSON      │
                                         └─────────────────────┘

Структура репозитория:

/cmd
  /api        — HTTP-сервер
  /cron       — обновление справочников, детект статусов станций
/internal
  /yandex     — клиент API, лимитер, ретраи, circuit breaker
  /cache      — интерфейс + Redis-реализация (cache-aside)
  /storage    — Postgres-репозитории
  /routing    — граф, алгоритм поиска, MCT-правила
  /airports   — соседние станции, автодетект закрытия
  /geo        — сборка GeoJSON для карты

4. Интеграция с Яндекс.Расписаниями

4.1 Используемые методы

Метод Назначение Частота обновления
/stations_list полный справочник станций/городов 1 раз в месяц
/nearest_stations поиск ближайших станций к точке (для соседних аэропортов) по требованию, кэш надолго (координаты не меняются)
/search рейсы между двумя станциями на дату на неделю вперёд (см. TTL-политику ниже)
/schedule все рейсы через конкретную станцию на дату используется отдельно для детекта закрытия узла
/thread детали конкретного рейса (опционально, real-time статус) не кэшируется / TTL в минутах

4.2 Квоты и защита

  • У Яндекс.Расписаний ограниченная суточная квота (порядка сотен запросов на бесплатном тарифе) — агрессивное кэширование не оптимизация, а необходимое условие работоспособности.
  • internal/yandex должен включать:
    • rate limiter (token bucket) на исходящие запросы;
    • circuit breaker — при массовых ошибках/таймаутах API временно переключаемся на отдачу устаревшего кэша вместо падения;
    • retry с экспоненциальным backoff на транзиентные ошибки.

5. Стратегия кэширования

5.1 Слои и TTL

Данные Хранилище TTL Комментарий
Справочник городов/станций Postgres (источник истины) + Redis (горячий доступ) 30 дней полный пересбор раз в месяц cron-джобой
/nearest_stations результаты Redis 30 дней координаты статичны, можно кэшировать вместе со справочником
/search (рейсы на конкретную дату) Redis 7 дней см. уточнение ниже
/schedule (рейсы через станцию, для детекта закрытия) Redis + запись в Postgres station_status 1 день нужна свежесть для детекта аномалий
/thread (статус конкретного рейса) не кэшируется или TTL 15 мин только если добавляем real-time статусы

Уточнение по /search: единый TTL в неделю недостаточно точен для дат, которые наступают уже завтра/послезавтра — там риск устаревания расписания выше (изменения рейсов, отмены). Рекомендуется дифференцировать:

  • дата поездки в пределах 2 дней от текущей — TTL 26 часов;
  • дата поездки 3+ дней вперёд — TTL 7 дней (как договорились).

5.2 Паттерн доступа

Cache-aside для всех слоёв: сначала Redis → промах → Postgres/Яндекс API → запись обратно в Redis. Ключи вида:

schedule:{from_station_id}:{to_station_id}:{date}
nearest:{lat}:{lon}:{radius}
station_flights:{station_id}:{date}

6. Модель данных (Postgres)

-- Справочник городов
CREATE TABLE cities (
    id            BIGINT PRIMARY KEY,
    yandex_code   TEXT UNIQUE NOT NULL,
    name          TEXT NOT NULL,
    country       TEXT NOT NULL,
    region        TEXT,
    lat           DOUBLE PRECISION,
    lon           DOUBLE PRECISION,
    tier          TEXT CHECK (tier IN ('small', 'million_plus')) NOT NULL,
    population    INT
);

-- Справочник станций (аэропорты, ж/д, автовокзалы)
CREATE TABLE stations (
    id            BIGINT PRIMARY KEY,
    yandex_code   TEXT UNIQUE NOT NULL,
    city_id       BIGINT REFERENCES cities(id),
    station_type  TEXT CHECK (station_type IN ('airport', 'train_station', 'bus_station')) NOT NULL,
    name          TEXT NOT NULL,
    lat           DOUBLE PRECISION,
    lon           DOUBLE PRECISION
);

-- Статус станции (для автодетекта закрытия)
CREATE TABLE station_status (
    station_id        BIGINT PRIMARY KEY REFERENCES stations(id),
    status            TEXT CHECK (status IN ('active', 'closed')) NOT NULL DEFAULT 'active',
    zero_since        TIMESTAMP,       -- с какого момента наблюдается 0 рейсов подряд
    last_seen_flight  TIMESTAMP,       -- дата последнего реального рейса
    updated_at        TIMESTAMP NOT NULL DEFAULT now()
);

-- Кандидаты соседних станций для города (геопоиск + ручной оверрайд)
CREATE TABLE station_neighbors (
    city_id       BIGINT REFERENCES cities(id),
    station_id    BIGINT REFERENCES stations(id),
    distance_km   NUMERIC,
    priority      INT,              -- порядок предпочтения при равных условиях
    source        TEXT CHECK (source IN ('geo', 'manual')) NOT NULL,
    is_excluded   BOOLEAN DEFAULT FALSE,  -- ручной блэклист (напр. неудобная логистика)
    PRIMARY KEY (city_id, station_id)
);

-- Правила минимального времени пересадки (MCT)
CREATE TABLE transfer_rules (
    id            SERIAL PRIMARY KEY,
    node_type     TEXT CHECK (node_type IN (
                      'airport_internal',
                      'station_internal',
                      'airport_to_city',
                      'city_to_city_via_airport'
                  )) NOT NULL,
    city_tier     TEXT CHECK (city_tier IN ('small', 'million_plus', NULL)),
    checkin_type  TEXT CHECK (checkin_type IN ('through', 'separate', 'n/a')) NOT NULL,
    min_minutes   INT NOT NULL
);

-- Базовые значения (см. раздел 7.4)
INSERT INTO transfer_rules (node_type, city_tier, checkin_type, min_minutes) VALUES
    ('airport_internal', NULL, 'through', 30),
    ('airport_internal', NULL, 'separate', 60),
    ('station_internal', NULL, 'n/a', 30),
    ('airport_to_city', 'small', 'n/a', 60),
    ('airport_to_city', 'million_plus', 'n/a', 90);

-- Сырой кэш ответов /search и /schedule (для отладки и восстановления после сбоев Redis)
CREATE TABLE schedule_cache (
    id            BIGSERIAL PRIMARY KEY,
    from_station  BIGINT REFERENCES stations(id),
    to_station    BIGINT REFERENCES stations(id),
    date          DATE NOT NULL,
    raw_response  JSONB NOT NULL,
    cached_at     TIMESTAMP NOT NULL DEFAULT now(),
    expires_at    TIMESTAMP NOT NULL
);

7. Расчёт маршрутов

7.1 Модель графа

Вершины — станции (аэропорт/вокзал/автовокзал) либо абстрактный "город" как логическая точка входа/выхода.

Рёбра двух типов:

  1. Реальные — конкретный рейс из Яндекс.Расписаний (время отправления/прибытия, перевозчик, номер рейса).
  2. Синтетические — переезды без прямого рейса в данных: город↔аэропорт, город↔соседний город через аэропорт. Время берётся из константной оценки (раздел 7.4), а не из API.
type NodeType int

const (
    NodeStation NodeType = iota
    NodeCity
)

type Node struct {
    ID   int64
    Type NodeType
}

type EdgeKind int

const (
    EdgeFlight EdgeKind = iota // реальный рейс
    EdgeSynthetic              // синтетический переезд
)

type Edge struct {
    From, To   Node
    Kind       EdgeKind
    Departure  time.Time // для synthetic — не используется, время считается относительно предыдущего сегмента
    Arrival    time.Time
    Duration   time.Duration
    Carrier    string    // для реальных рейсов; для оценки through check-in
    TransportType string // plane / train / bus
}

7.2 Ограничение API и стратегия построения графа

Важно: Яндекс.Расписания не отдают дамп всей транспортной сети (в отличие от GTFS-фидов), только точечные ответы "откуда → куда → когда" или "все рейсы через станцию". Это значит, что классический RAPTOR в его "учебной" форме (когда весь timetable сети загружен в память) неприменим напрямую — иначе для многопересадочного поиска пришлось бы делать запросы по всем возможным парам станций, что моментально исчерпает квоту API.

Решение — ленивое (lazy) расширение графа с ограничением по хабам:

  1. Строим список опорных узлов (hub stations) — крупные транспортные узлы (областные центры, крупные ж/д станции, основные аэропорты). Список формируется вручную/по населению города + автоматически по числу исходящих рейсов.
  2. Поиск маршрута — не полный RAPTOR по всей сети, а BFS/Dijkstra с ограничением глубины (максимум 45 пересадок), где на каждом шаге расширения графа мы:
    • запрашиваем /search только от текущего узла к следующим узлам-кандидатам (соседние хабы + станции в окрестности точки назначения), а не ко всем станциям сети;
    • кэшируем результат немедленно, чтобы повторные запросы в рамках того же поиска (и от других пользователей) не тратили квоту повторно.
  3. Финальный набор маршрутов ранжируется и фильтруется до Pareto-фронта (см. 7.5), а не единственного "оптимального" пути.

Это компромисс между "честным" RAPTOR (нужен полный фид, недоступен) и наивной попарной Дейкстрой (не обрабатывает time-dependent расписания). При росте нагрузки эту стратегию можно уточнять: например, кэшировать не только точечные ответы, но и постепенно накапливать локальный "теневой" timetable по наиболее популярным направлениям.

7.3 Мультимодальность

Граф не различает вид транспорта на уровне алгоритма поиска — TransportType используется только как атрибут для отображения и, при необходимости, для пользовательских фильтров ("не показывать автобусы"). Правила пересадок (7.4) зависят от типа узла, а не от вида транспорта напрямую.

7.4 Минимальное время пересадки (MCT)

Время пересадки — функция трёх параметров: тип узла, тип регистрации, размер города (только для узла "аэропорт↔город").

node_type city_tier checkin_type min_minutes
airport_internal through (единый перевозчик/через регистрация) 30
airport_internal separate (разные перевозчики / нет единого билета) 60
station_internal n/a 30
airport_to_city small n/a 60
airport_to_city million_plus n/a 90
type CheckinType int

const (
    CheckinThrough CheckinType = iota
    CheckinSeparate
)

type TransferContext struct {
    NodeType    string // соответствует enum в transfer_rules
    CheckinType CheckinType
    CityTier    string // "small" / "million_plus", актуально только для airport_to_city
}

func MinTransferTime(ctx TransferContext) time.Duration {
    // читает значение из transfer_rules (закэшировано в памяти при старте сервиса)
}

Определение checkin_type: данные Яндекс.Расписаний не содержат информации о тарифных/интерлайн-соглашениях между перевозчиками, поэтому это поле нельзя определить достоверно. Правило по умолчанию:

  • если оба сегмента выполняет один и тот же перевозчик (совпадает код авиакомпании в данных) → допускаем эвристику through;
  • во всех остальных случаях, включая отсутствие данных о перевозчике → безопасный дефолт separate (закладываем большее время, чтобы не предлагать нереализуемые стыковки).

Соседний аэропорт как композиция рёбер: узел city_to_city_via_airport — не отдельная константа, а сумма airport_to_city(город назначения, tier) + реальный сегмент переезда между городами (если он есть в расписании Яндекса, например автобус/электричка) либо ещё один синтетический переезд, если прямого сообщения нет.

7.5 Ранжирование результатов (Pareto-фронт)

Поскольку критериев несколько (время в пути, число пересадок, приблизительная стоимость, если доступна), пользователю показывается не один "лучший" маршрут, а набор недоминируемых вариантов:

  • маршрут A доминирует над B, если A не хуже B по всем критериям и строго лучше хотя бы по одному;
  • в выдачу попадают только недоминируемые маршруты, с сортировкой по умолчанию "быстрее всего" и возможностью переключить на "меньше пересадок" / "дешевле" (если есть данные о цене).

8. Соседние аэропорты и автодетект закрытия

8.1 Формирование кандидатов

  1. Геопоиск — через /nearest_stations находим все станции в радиусе N км от города (радиус настраиваемый, например 150200 км для аэропортов).
  2. Ручной оверрайд — таблица station_neighbors.source = 'manual' позволяет:
    • добавить кандидата, которого геопоиск не находит или занижает в приоритете (например, при неудобной геометрии, но хорошей логистике);
    • исключить кандидата через is_excluded = true (например, географически близкий аэропорт без нормального наземного сообщения).
  3. Итоговый список кандидатов для города = объединение geo + manual, за вычетом excluded, отсортированное по priority.

8.2 Автодетект закрытия

Логика — бинарный сигнал по числу рейсов через станцию (не сложный статистический baseline, простого порога "N дней подряд ноль" достаточно, так как естественная сезонность/праздники снижают число рейсов, но не обнуляют его полностью):

  1. Ежедневная cron-джоба вызывает /schedule для каждой отслеживаемой станции, считает число рейсов на ближайшие даты.
  2. Если сегодня 0 рейсов, а на предыдущих замерах было > 0 — фиксируем zero_since = now(), статус пока не меняем (защита от разового сбоя API).
  3. Если 0 рейсов держится N дней подряд (рекомендуемое значение N = 3, настраиваемое) — статус станции переключается в closed. Станция исключается из построения маршрутов; вместо неё автоматически подставляются соседние кандидаты из station_neighbors.
  4. Возврат в active — сразу при первом появлении >0 рейсов (гистерезис на открытие не нужен: ложное срабатывание "уже открыт" не критично, в худшем случае пользователю покажут лишний вариант маршрута).
  5. Ручной статус (например, объявление о ремонте до конкретной даты) может быть выставлен через админку заранее, не дожидаясь автоматического детекта постфактум — таблица station_status поддерживает и автоматические, и ручные записи (нужно добавить поле source enum('auto','manual'), см. риски).

9. Визуализация маршрута на карте

  • Каждый найденный маршрут (реальные + синтетические сегменты) конвертируется в GeoJSON FeatureCollection:
    • LineString для каждого сегмента пути (координаты станций начала/конца сегмента);
    • Point-маркеры для каждой пересадочной станции с popup-инфо (время стыковки, тип пересадки);
    • визуально различать реальные сегменты (сплошная линия, цвет по виду транспорта) и синтетические (пунктир).
  • Рендер на фронтенде — Leaflet + OSM-тайлы, без зависимости от платных карто-провайдеров.
  • Эндпоинт отдаёт готовый GeoJSON, фронт не должен пересчитывать геометрию.

10. API (REST, черновой список эндпоинтов)

Метод Путь Описание
GET /v1/cities?query= автокомплит городов из справочника
GET /v1/cities/{id}/stations список станций города (с учётом соседних, если основная закрыта)
POST /v1/routes/search тело: from_city_id, to_city_id, date, filters. Ответ: Pareto-набор маршрутов
GET /v1/routes/{search_id}/{route_id}/geojson геометрия конкретного маршрута для карты
GET /v1/stations/{id}/status текущий статус станции (для отладки/админки)
POST /internal/admin/stations/{id}/status ручное переопределение статуса станции (требует авторизации)

11. Нефункциональные требования

  • Производительность: ответ на /v1/routes/search — не более 35 секунд для маршрутов, полностью покрытых кэшем; для холодного кэша с несколькими сегментами — до 15 секунд (с учётом ленивых запросов к API Яндекса), с индикацией прогресса на фронте при долгом поиске.
  • Отказоустойчивость: при недоступности API Яндекса — деградация до отдачи устаревшего кэша с явной пометкой "данные могут быть неактуальны", не полный отказ в обслуживании.
  • Наблюдаемость: метрики — hit-rate кэша по слоям, остаток квоты API Яндекса, число ошибок circuit breaker, среднее время поиска маршрута.
  • Тестирование: алгоритм поиска маршрута (раздел 7) должен покрываться unit-тестами на синтетических timetable-фикстурах, без реальных вызовов API.

12. Этапы разработки (Roadmap)

Этап 1 — MVP

  • Один вид транспорта (например, поезда) без синтетических рёбер.
  • Прямые маршруты + максимум 1 пересадка.
  • Базовая визуализация на карте.
  • Кэш справочников и /search по описанным TTL.

Этап 2 — Мультимодальность и MCT

  • Добавление самолётов и автобусов в граф.
  • Синтетические рёбра "город↔аэропорт" с правилами MCT.
  • Ручные соседние аэропорты (station_neighbors.source = 'manual').

Этап 3 — Глубокий поиск и автодетект

  • Ленивое расширение графа через hub-узлы, глубина до 45 пересадок.
  • Pareto-ранжирование результатов.
  • Автодетект закрытия станций и автоматическое переключение на соседние.

Этап 4 — Полировка

  • Учёт цены как критерия (если появится источник данных).
  • Уведомления об изменении/отмене рейсов в уже построенном маршруте.
  • Персонализация (сохранённые города, история поиска).

13. Открытые вопросы и риски

  1. Источник цены — Яндекс.Расписания не всегда отдают стоимость билета; если цена нужна как критерий ранжирования, потребуется дополнительный источник данных (риск для этапа 4).
  2. Достоверность checkin_type — эвристика "один перевозчик → through check-in" может ошибаться (например, при кодшеринге). Стоит заложить дополнительный буфер времени или пометку "оценочно" в UI.
  3. Разделение station_status.source на auto/manual — нужно решить, как ручной статус взаимодействует с автодетектом (не должен ли автодетект перезаписывать ручную запись раньше времени).
  4. Квота API при росте нагрузки — стратегия lazy-hub-expansion снижает число запросов, но при масштабировании потребуется либо повышенный тариф API, либо собственный накопительный кэш ("теневой" timetable) по популярным направлениям.
  5. Актуальность hub-списка — список опорных узлов для построения графа нужно периодически пересматривать (новые маршруты, изменение трафика по городам).