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

424 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Техническое задание: сервис планирования мультимодальных поездок
**Версия:** 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)
```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 с ограничением глубины (максимум 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 |
```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 км от города (радиус настраиваемый, например 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-списка** — список опорных узлов для построения графа нужно периодически пересматривать (новые маршруты, изменение трафика по городам).