235 lines
13 KiB
Markdown
235 lines
13 KiB
Markdown
# ТЗ: Скачивание трека из popup на карте
|
||
|
||
**Work Item:** ET-011
|
||
**Стадия:** Анализ → Architecture
|
||
**Автор:** analyst
|
||
**Дата:** 2026-06-03
|
||
|
||
---
|
||
|
||
## 1. Сводка
|
||
|
||
Добавить в существующий popup публичного GPS-трека (слой ET-008) кнопку
|
||
«Скачать», которая запрашивает с сервера GPX-файл и сохраняет его в
|
||
загрузки пользователя. Новый backend-эндпоинт собирает GPX 1.1 из
|
||
геометрии трека в БД `gps_tracks.sqlite`.
|
||
|
||
## 2. Функциональные требования
|
||
|
||
### REQ-F-01 — Кнопка «Скачать» в popup трека
|
||
|
||
В popup публичного трека (создаётся в `_renderTrackPopupHtml(props)`,
|
||
`src/web/gps_tracks.js`, l.463) **должна появляться кнопка «Скачать»**.
|
||
|
||
- Иконка: download (SVG, как в `sheet-route` `downloadGPX`, l.135–137 в
|
||
`index.html`).
|
||
- Tooltip / aria-label: «Скачать GPX».
|
||
- Размещение: в правом верхнем углу popup, рядом с названием трека,
|
||
или отдельной строкой в конце popup перед источниками — на усмотрение
|
||
архитектора, но **всегда видна без скролла**.
|
||
- Тапабельная зона: ≥ 32×32 CSS px (mobile-friendly, REQ-NF-04 ниже).
|
||
|
||
### REQ-F-02 — Backend: эндпоинт скачивания
|
||
|
||
Реализовать в роутере `src/api/gps_tracks/endpoint.py` новый GET-эндпоинт:
|
||
|
||
```
|
||
GET /api/gps-tracks/{track_id}/download
|
||
GET /api/gps-tracks/{track_id}/download?format=gpx (синоним)
|
||
```
|
||
|
||
Параметры:
|
||
- `track_id` (path, int, обязательный) — `tracks.id` из БД.
|
||
- `format` (query, optional, default=`gpx`) — формат файла.
|
||
Допустимые значения для текущей итерации: `gpx`.
|
||
(При закрытии Q-2 = «делаем KML» — добавится `kml`.)
|
||
|
||
Поведение:
|
||
- 200 + `Content-Type: application/gpx+xml` (для GPX) или
|
||
`application/vnd.google-earth.kml+xml` (для KML).
|
||
- `Content-Disposition: attachment; filename="<safe-name>.gpx"; filename*=UTF-8''<urlencoded-name>.gpx`
|
||
(RFC 5987, REQ-NF-05 ниже).
|
||
- 404, если `track_id` не существует.
|
||
- 400, если `format` не входит в whitelist.
|
||
- 403, если источник трека запрещает реэкспорт (см. REQ-F-06 и Q-1 в BRD).
|
||
|
||
### REQ-F-03 — Содержимое GPX
|
||
|
||
GPX-файл должен соответствовать схеме GPX 1.1
|
||
(http://www.topografix.com/GPX/1/1) и содержать:
|
||
|
||
- Корневой `<gpx>` с атрибутами:
|
||
- `version="1.1"`
|
||
- `creator="Enduro Trails"`
|
||
- `xmlns="http://www.topografix.com/GPX/1/1"`
|
||
- `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`
|
||
- `xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"`
|
||
- Блок `<metadata>` с:
|
||
- `<name>` — `tracks.name` или «Без названия».
|
||
- `<desc>` — `tracks.description` (если есть).
|
||
- `<time>` — `tracks.created_at` в ISO-8601 (если есть, иначе пропустить).
|
||
- `<author><name>` — `tracks.user` (если есть).
|
||
- `<link href="<external_url>"><text>Источник: <source_id></text></link>`
|
||
— по одному `<link>` на каждый элемент `external_urls`.
|
||
- `<copyright author="Enduro Trails"><license>https://www.openstreetmap.org/copyright</license></copyright>`
|
||
— для OSM-источника. Для других — без `<copyright>` либо со ссылкой
|
||
на исходный URL.
|
||
- Ровно один `<trk>` с:
|
||
- `<name>` — `tracks.name`.
|
||
- `<type>` — `activity_type` (например, `enduro`).
|
||
- Ровно один `<trkseg>` с `<trkpt lat="..." lon="...">` для каждой
|
||
координаты из WKB-геометрии `tracks.geom`. **Без** `<ele>` и `<time>`
|
||
(см. BRD A2).
|
||
|
||
### REQ-F-04 — Имя файла
|
||
|
||
Имя файла (для `Content-Disposition` и `filename*`) формируется так:
|
||
|
||
1. Берём `tracks.name`. Если пустое / NULL — используем `track-<id>`.
|
||
2. Заменяем все недопустимые для FAT/NTFS символы (`/ \ : * ? " < > |`)
|
||
на `_`.
|
||
3. Триммим до 80 символов.
|
||
4. Транслитерация **не нужна** — современные браузеры понимают
|
||
`filename*=UTF-8''…` (RFC 5987).
|
||
5. Расширение: `.gpx` (или `.kml`).
|
||
|
||
Например: `tracks.name = "По грязи к Чёрному озеру"` →
|
||
`По грязи к Чёрному озеру.gpx` (через `filename*=UTF-8''%D0%9F%D0%BE…`).
|
||
|
||
### REQ-F-05 — Поведение на фронте
|
||
|
||
При клике на кнопку «Скачать»:
|
||
|
||
1. Не закрывать popup (или закрывать — на усмотрение архитектора, главное
|
||
консистентно с остальными кнопками в проекте). Рекомендация: **не
|
||
закрывать**, чтобы пользователь видел индикатор/успех.
|
||
2. Сделать GET-запрос на `/api/gps-tracks/{id}/download` через
|
||
`<a href="..." download="...">.click()` (стандартный паттерн, отлично
|
||
работает в desktop и mobile-браузерах) **или** через `fetch` + `Blob`
|
||
+ `URL.createObjectURL` — выбор за архитектором, см. R-1 в BRD.
|
||
3. На время запроса показать спиннер/индикатор на самой кнопке (опц.) —
|
||
нужно если бэк > 200 ms. Hint: трек на 50 000 точек собирается
|
||
≈ 80–150 ms (см. NFR-01), так что индикатор большинству не нужен.
|
||
4. При ошибке (HTTP ≠ 200) — показать `showToast(...)` (функция уже
|
||
есть в проекте) с человекочитаемым сообщением:
|
||
- 403 → «Источник запрещает скачивание. Откройте трек на сайте
|
||
источника.»
|
||
- 404 → «Трек не найден.»
|
||
- 5xx / network → «Не удалось скачать. Попробуйте ещё раз.»
|
||
|
||
### REQ-F-06 — Защита по лицензии источника (зависит от Q-1)
|
||
|
||
Если Owner закрывает Q-1 как «только OSM»:
|
||
|
||
- Backend проверяет `tracks.sources_json`. Если **ни одного** из
|
||
источников не относится к разрешённому whitelist'у (по умолчанию
|
||
`["osm"]`) — возвращает 403 c JSON `{"detail":"source_forbidden",
|
||
"external_urls":[...]}`.
|
||
- Frontend в обработчике 403 показывает toast и, если есть
|
||
`external_urls`, кнопку «Открыть на сайте источника».
|
||
|
||
Если Owner отвечает «всё разрешено» — этот REQ становится no-op
|
||
(вырезать).
|
||
|
||
### REQ-F-07 — Логирование
|
||
|
||
Каждое успешное скачивание логируется server-side:
|
||
`uvicorn` access-log + (опц.) отдельная строка в stdout формата
|
||
`track_download id=<id> source=<sources> size_bytes=<n> ip=<remote>`.
|
||
Это нужно для NFR-06 (наблюдаемость).
|
||
|
||
## 3. Нефункциональные требования
|
||
|
||
### REQ-NF-01 — Производительность
|
||
|
||
Сборка GPX и отдача для трека до **50 000 точек** — не дольше **300 ms**
|
||
от запроса до начала ответа (P95 на текущем железе test-среды).
|
||
Размер ответа для типичного трека 100 км / 5 000 точек — до **800 КБ**
|
||
(чистый XML, без gzip; ответ может быть gzip'нут средствами uvicorn).
|
||
|
||
### REQ-NF-02 — Потолок размера ответа
|
||
|
||
Если число точек в треке `> 200 000` (защита от patho-кейсов) —
|
||
возвращать 413 `Payload Too Large` с сообщением «Трек слишком большой
|
||
для скачивания». Реализация: проверка `tracks.points_count` до сборки XML.
|
||
|
||
### REQ-NF-03 — Соответствие схеме GPX 1.1
|
||
|
||
Полученный файл должен проходить валидацию по схеме
|
||
http://www.topografix.com/GPX/1/1/gpx.xsd без warnings/errors. Тест в
|
||
`tests/api/test_gps_tracks_download.py` (см. test plan).
|
||
|
||
### REQ-NF-04 — UX mobile
|
||
|
||
- Кнопка «Скачать» должна быть удобно тапабельной на мобильных
|
||
(≥ 32×32 CSS px).
|
||
- Popup не должен «прыгать» из-за появления кнопки — высота
|
||
фиксирована или растёт плавно.
|
||
- При ширине viewport < 420 px кнопка остаётся видимой (popup имеет
|
||
`max-width: 300px` — см. `gps_tracks.js` l.514).
|
||
|
||
### REQ-NF-05 — Заголовок Content-Disposition
|
||
|
||
Заголовок должен поддерживать UTF-8 имена через RFC 5987:
|
||
```
|
||
Content-Disposition: attachment; filename="track.gpx"; filename*=UTF-8''%D0%9F%D0%BE…
|
||
```
|
||
Параметр `filename` (без `*`) — ASCII-fallback (транслит или `track-<id>.gpx`).
|
||
|
||
### REQ-NF-06 — Наблюдаемость
|
||
|
||
- 200/4xx/5xx ответы видны в `uvicorn` access-log.
|
||
- Стек-трейсы 5xx уходят в stderr (текущая практика FastAPI/uvicorn).
|
||
- Метрики (RPS / latency) — не требуются в этой итерации.
|
||
|
||
### REQ-NF-07 — Безопасность
|
||
|
||
- `track_id` — int, парсится FastAPI, защита от SQL-инjection
|
||
встроенная.
|
||
- Имя файла санитизуется (REQ-F-04) — защита от path-traversal в
|
||
загрузках.
|
||
- `Access-Control-Allow-Origin: *` уже стоит в CORS middleware — не
|
||
трогаем; iframe-embed возможен.
|
||
|
||
## 4. Out of scope (явно)
|
||
|
||
- KML — в backlog (см. Q-2). Если Owner закрывает Q-2 как «делаем» —
|
||
REQ-F-02 расширяется (`format=kml`), но это не предмет данной итерации.
|
||
- Сохранение скачанного трека в IndexedDB / в `sheet-gpx` (как
|
||
пользовательский GPX по ET-006) — отдельная фича.
|
||
- Bulk-download (несколько треков). Только один за запрос.
|
||
- Конвертация формата (waypoints, маркеры).
|
||
|
||
## 5. Артефакты, к которым прикасаемся
|
||
|
||
- `src/web/gps_tracks.js` — функция `_renderTrackPopupHtml(props)` и
|
||
(вероятно) обработчик клика на новую кнопку.
|
||
- `src/web/app.css` (или `gps_tracks.js` inline-стили) — стиль кнопки.
|
||
- `src/api/gps_tracks/endpoint.py` — добавляется новый route.
|
||
- `src/api/gps_tracks/db.py` (возможно) — функция `get_track_by_id()`.
|
||
- `tests/api/test_gps_tracks_download.py` — новые тесты (см. test plan).
|
||
- `tests/web/test_gps_tracks_popup.spec.ts` или аналог — UI-тесты
|
||
(Playwright, см. `04b-ui-test-cases.md`).
|
||
- ADR `docs/work-items/ET-011/06-adr/*.md` (создаст architect): про
|
||
механизм отдачи (link vs blob), про обработку лицензии источника.
|
||
|
||
## 6. Зависимости
|
||
|
||
- Слой ET-008 «Публичные треки» уже в проде (тестовая среда). Этот
|
||
work item **расширяет** его popup.
|
||
- БД `gps_tracks.sqlite` инициализируется через миграцию
|
||
`migrations/gps_tracks_001_init.sql` — её менять не нужно (все
|
||
необходимые поля уже есть: `id`, `name`, `description`,
|
||
`activity_type`, `user`, `created_at`, `length_m`, `points_count`,
|
||
`geom`, `sources_json`, `external_urls_json`).
|
||
|
||
## 7. Глоссарий
|
||
|
||
- **Public track** — публичный GPS-трек из таблицы `tracks` в БД
|
||
`gps_tracks.sqlite`. Источник — OSM, EnduroRussia, Wikiloc, ttrails и
|
||
т.п.
|
||
- **GPX** — GPS Exchange Format 1.1, XML-формат для треков и точек.
|
||
- **KML** — Keyhole Markup Language 2.2, XML-формат Google Earth.
|
||
- **Popup** — MapLibre `maplibregl.Popup`, всплывающее окно по клику на
|
||
feature.
|