# Техническое задание: сервис планирования мультимодальных поездок **Версия:** 0.1 (черновик для старта разработки) **Язык реализации:** Go **Дата:** 13.08.2026 --- ## 1. Концепция продукта ### 1.1 Проблема Существующие агрегаторы (Aviasales, Туту.ру и др.) в основном монотранспортны (только самолёты или только поезда) и ограничивают глубину поиска 1–2 пересадками из соображений UX и производительности. В результате пользователь не видит: - маршруты, комбинирующие разные виды транспорта (самолёт + поезд + автобус); - маршруты через альтернативные (соседние) узлы, когда основной недоступен (например, аэропорт закрыт на ремонт); - маршруты глубже 2 пересадок, даже если они дешевле или единственно возможны. ### 1.2 Отличительные особенности (УТП) 1. **Мультимодальный поиск** — единый граф из самолётов, поездов, электричек, автобусов на основе данных Яндекс.Расписаний. 2. **Произвольная глубина пересадок** — не ограничиваемся 1–2 пересадками, ищем по всему разумному пространству вариантов. 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 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) ```sql -- Справочник городов 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. ```go 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 с ограничением глубины (максимум 4–5 пересадок)**, где на каждом шаге расширения графа мы: - запрашиваем `/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 | ```go 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 км от города (радиус настраиваемый, например 150–200 км для аэропортов). 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` — не более 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. Открытые вопросы и риски 1. **Источник цены** — Яндекс.Расписания не всегда отдают стоимость билета; если цена нужна как критерий ранжирования, потребуется дополнительный источник данных (риск для этапа 4). 2. **Достоверность `checkin_type`** — эвристика "один перевозчик → through check-in" может ошибаться (например, при кодшеринге). Стоит заложить дополнительный буфер времени или пометку "оценочно" в UI. 3. **Разделение `station_status.source` на `auto`/`manual`** — нужно решить, как ручной статус взаимодействует с автодетектом (не должен ли автодетект перезаписывать ручную запись раньше времени). 4. **Квота API при росте нагрузки** — стратегия lazy-hub-expansion снижает число запросов, но при масштабировании потребуется либо повышенный тариф API, либо собственный накопительный кэш ("теневой" timetable) по популярным направлениям. 5. **Актуальность hub-списка** — список опорных узлов для построения графа нужно периодически пересматривать (новые маршруты, изменение трафика по городам).