From 8b7b98814194a7b8da0de03109ea0c47611f357b Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Thu, 13 Aug 2026 16:20:11 +0300 Subject: [PATCH] Specification --- docs/specification.md | 423 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 docs/specification.md diff --git a/docs/specification.md b/docs/specification.md new file mode 100644 index 0000000..f913e65 --- /dev/null +++ b/docs/specification.md @@ -0,0 +1,423 @@ +# Техническое задание: сервис планирования мультимодальных поездок + +**Версия:** 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-списка** — список опорных узлов для построения графа нужно периодически пересматривать (новые маршруты, изменение трафика по городам).