33 KiB
Техническое задание: сервис планирования мультимодальных поездок
Версия: 0.1 (черновик для старта разработки) Язык реализации: Go Дата: 13.08.2026
1. Концепция продукта
1.1 Проблема
Существующие агрегаторы (Aviasales, Туту.ру и др.) в основном монотранспортны (только самолёты или только поезда) и ограничивают глубину поиска 1–2 пересадками из соображений UX и производительности. В результате пользователь не видит:
- маршруты, комбинирующие разные виды транспорта (самолёт + поезд + автобус);
- маршруты через альтернативные (соседние) узлы, когда основной недоступен (например, аэропорт закрыт на ремонт);
- маршруты глубже 2 пересадок, даже если они дешевле или единственно возможны.
1.2 Отличительные особенности (УТП)
- Мультимодальный поиск — единый граф из самолётов, поездов, электричек, автобусов на основе данных Яндекс.Расписаний.
- Произвольная глубина пересадок — не ограничиваемся 1–2 пересадками, ищем по всему разумному пространству вариантов.
- Автоматическое использование соседних аэропортов/вокзалов, включая автодетект закрытия основного узла.
- Визуализация всего маршрута на карте — сквозная линия пути со всеми сегментами и пересадками.
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 1–5 мин | только если добавляем real-time статусы |
Уточнение по /search: единый TTL в неделю недостаточно точен для дат, которые наступают уже завтра/послезавтра — там риск устаревания расписания выше (изменения рейсов, отмены). Рекомендуется дифференцировать:
- дата поездки в пределах 2 дней от текущей — TTL 2–6 часов;
- дата поездки 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 Модель графа
Вершины — станции (аэропорт/вокзал/автовокзал) либо абстрактный "город" как логическая точка входа/выхода.
Рёбра двух типов:
- Реальные — конкретный рейс из Яндекс.Расписаний (время отправления/прибытия, перевозчик, номер рейса).
- Синтетические — переезды без прямого рейса в данных: город↔аэропорт, город↔соседний город через аэропорт. Время берётся из константной оценки (раздел 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) расширение графа с ограничением по хабам:
- Строим список опорных узлов (hub stations) — крупные транспортные узлы (областные центры, крупные ж/д станции, основные аэропорты). Список формируется вручную/по населению города + автоматически по числу исходящих рейсов.
- Поиск маршрута — не полный RAPTOR по всей сети, а BFS/Dijkstra с ограничением глубины (максимум 4–5 пересадок), где на каждом шаге расширения графа мы:
- запрашиваем
/searchтолько от текущего узла к следующим узлам-кандидатам (соседние хабы + станции в окрестности точки назначения), а не ко всем станциям сети; - кэшируем результат немедленно, чтобы повторные запросы в рамках того же поиска (и от других пользователей) не тратили квоту повторно.
- запрашиваем
- Финальный набор маршрутов ранжируется и фильтруется до 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 Формирование кандидатов
- Геопоиск — через
/nearest_stationsнаходим все станции в радиусе N км от города (радиус настраиваемый, например 150–200 км для аэропортов). - Ручной оверрайд — таблица
station_neighbors.source = 'manual'позволяет:- добавить кандидата, которого геопоиск не находит или занижает в приоритете (например, при неудобной геометрии, но хорошей логистике);
- исключить кандидата через
is_excluded = true(например, географически близкий аэропорт без нормального наземного сообщения).
- Итоговый список кандидатов для города = объединение geo + manual, за вычетом excluded, отсортированное по
priority.
8.2 Автодетект закрытия
Логика — бинарный сигнал по числу рейсов через станцию (не сложный статистический baseline, простого порога "N дней подряд ноль" достаточно, так как естественная сезонность/праздники снижают число рейсов, но не обнуляют его полностью):
- Ежедневная cron-джоба вызывает
/scheduleдля каждой отслеживаемой станции, считает число рейсов на ближайшие даты. - Если сегодня 0 рейсов, а на предыдущих замерах было > 0 — фиксируем
zero_since = now(), статус пока не меняем (защита от разового сбоя API). - Если 0 рейсов держится N дней подряд (рекомендуемое значение N = 3, настраиваемое) — статус станции переключается в
closed. Станция исключается из построения маршрутов; вместо неё автоматически подставляются соседние кандидаты изstation_neighbors. - Возврат в
active— сразу при первом появлении >0 рейсов (гистерезис на открытие не нужен: ложное срабатывание "уже открыт" не критично, в худшем случае пользователю покажут лишний вариант маршрута). - Ручной статус (например, объявление о ремонте до конкретной даты) может быть выставлен через админку заранее, не дожидаясь автоматического детекта постфактум — таблица
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— не более 3–5 секунд для маршрутов, полностью покрытых кэшем; для холодного кэша с несколькими сегментами — до 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-узлы, глубина до 4–5 пересадок.
- Pareto-ранжирование результатов.
- Автодетект закрытия станций и автоматическое переключение на соседние.
Этап 4 — Полировка
- Учёт цены как критерия (если появится источник данных).
- Уведомления об изменении/отмене рейсов в уже построенном маршруте.
- Персонализация (сохранённые города, история поиска).
13. Открытые вопросы и риски
- Источник цены — Яндекс.Расписания не всегда отдают стоимость билета; если цена нужна как критерий ранжирования, потребуется дополнительный источник данных (риск для этапа 4).
- Достоверность
checkin_type— эвристика "один перевозчик → through check-in" может ошибаться (например, при кодшеринге). Стоит заложить дополнительный буфер времени или пометку "оценочно" в UI. - Разделение
station_status.sourceнаauto/manual— нужно решить, как ручной статус взаимодействует с автодетектом (не должен ли автодетект перезаписывать ручную запись раньше времени). - Квота API при росте нагрузки — стратегия lazy-hub-expansion снижает число запросов, но при масштабировании потребуется либо повышенный тариф API, либо собственный накопительный кэш ("теневой" timetable) по популярным направлениям.
- Актуальность hub-списка — список опорных узлов для построения графа нужно периодически пересматривать (новые маршруты, изменение трафика по городам).