Files
EventHubSpec/GEO.md
T
aleksey b79fd81260 docs(geo): канон переведён на внешний геокодер OpenCage
Стек/Ops/контракт: OpenCage API через прокси бэкенда, ключ не покидает
сервер; source=opencage; free-тариф 2500 запросов/сутки и 1 rps,
402/403/429 апстрима -> 503 geo_unavailable; ToS разрешает хранение
результатов. Self-host Photon исключён из стека.

Refs EventHub/EventHubSpec#21
2026-08-18 23:30:23 +03:00

226 lines
15 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.
# Гео и карты (Фаза 3, волна 2)
Канон геокодирования, хранения координат и UX карты. Трекер: [Spec#21](https://git.sabilin.com/EventHub/EventHubSpec/issues/21), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19).
Реализация: [Back#77](https://git.sabilin.com/EventHub/EventHubBack/issues/77), [Front#74](https://git.sabilin.com/EventHub/EventHubFront/issues/74), [DevOps#16](https://git.sabilin.com/EventHub/EventHubDevOps/issues/16). Этот документ — контракт; код подтягивается после него.
## Цель
Владелец задаёт место события (и org-default календаря) через поиск адреса и пин на карте. Координаты **хранятся у нас**. Участник ищет **ближайшие** commercial-календари и события с опциональным радиусом. Гость видит мини-карту и может открыть ту же точку в привычном приложении карт.
Критерий выигрыша: не зависеть от ToS Google/Яндекс на кэш геокода; не блокировать create события, если геокодер лежит.
## Почему не Google / Яндекс как провайдеры
Событие — общая сущность: владелец пишет `lat`/`lon`, участники и Discover читают те же числа.
- **Google Geocoding:** кэш lat/lng до 30 дней, затем удалить; бессрочно — `place_id`. Исключение «кэш изолирован на одного end-user» **не покрывает** общую точку события. Результат Google нельзя показывать на чужой карте.
- **Яндекс (бесплатный API):** данные геокодера нельзя сохранять в свою БД. Коммерческая лицензия — отдельный договор и обязательный логотип / «Открыть в Картах».
**Не используем:** Google Maps JavaScript API, Geocoding/Places, Яндекс JS API / Геокодер, ключи карт.
**Разрешено:** outbound-ссылки на Google Maps и Яндекс.Карты с **нашими** координатами (OpenCage или пин пользователя). Это не вызов их API и не хранение их ответа.
## Стек (вариант Ц)
| Слой | Выбор | Не берём |
|------|--------|----------|
| Карта в браузере | [MapLibre GL JS](https://maplibre.org/) | Leaflet + растр `tile.openstreetmap.org` (политика тайлов OSMF) |
| Тайлы | [OpenFreeMap](https://openfreemap.org/) публичный инстанс, без ключа | Carto/MapTiler Free (не для коммерции EventHub); свой tile-server |
| Геокод | **[OpenCage](https://opencagedata.com)** (внешний API), прокси только через EventHubBack, ключ не покидает бэкенд | публичный Nominatim OSMF (1 rps, autocomplete запрещён); `photon.komoot.io`; self-host Photon (вес индекса, RAM) |
| Хранение | `address` + `lat`/`lon` в EventHub | `place_id` Google, сырой ответ Яндекса |
ToS OpenCage разрешает бессрочно хранить результаты геокодирования — кэш и сохранённые координаты события легальны. Данные OSM — [ODbL](https://www.openstreetmap.org/copyright). На карте видимая атрибуция: «© OpenStreetMap contributors» со ссылкой на copyright.
```
Front (MapLibre) --tiles--> OpenFreeMap
Front --suggest/geocode/reverse--> Back /v1/geo --> OpenCage API (ключ в env бэка)
Back --persist--> event.location / calendar.settings.default_location
```
Front **не** ходит в OpenCage и Nominatim напрямую (ключ не должен попасть в браузер).
## Модель данных (уже есть)
- Событие: `location` = `{ address, lat?, lon? }`.
- Календарь: `settings.default_location` = `{ address, lat?, lon? }` (как сейчас: `address` непустой; `lat`/`lon` оба или ни одного).
Волна 2:
- **Address-only разрешён.** Create/update события с адресом без координат **не** отбрасывает `location` (сейчас `parse_location` без пары lat/lon даёт `undefined`).
- `lat`/`lon` — оба или ни одного; не пара `0, 0` как «пусто» в API (пусто = поля отсутствуют / `null`).
- Поиск «рядом»: см. § «Ближайшие календари и события». Сейчас geo режет только события; календари по `default_location` — волна 2 (Back#77).
## API геокодирования (контракт Back#77)
`POST` geocode/reverse: user Bearer (пикер). `GET /v1/geo/suggest`: **без токена** (Discover «центр по адресу» для гостя) + rate-limit по IP; с Bearer — тот же лимит на user. Ключ OpenCage с браузера не светим.
Кэш одинаковых `q`+`lang` (ETS/Mnesia, TTL дни). Таймаут апстрима ~3 s.
### `GET /v1/geo/suggest?q=&lang=`
Typeahead. Клиент debounce (≥300 ms). Пустой / слишком короткий `q``400` `{error: "invalid_query"}`.
Ответ `200`:
```json
{
"results": [
{ "address": "Москва, Красная площадь", "lat": 55.7539, "lon": 37.6208 }
]
}
```
Пустой список — валидный `200`, не ошибка.
### `POST /v1/geo/geocode`
Тело: `{ "q": "…", "lang": "ru" }` (`lang` опционален, иначе язык пользователя / `ru`).
- `200`: `{ "address": "…", "lat": 55.75, "lon": 37.62, "source": "opencage" }`
- нет хита: `404` `{error: "not_found"}`
- OpenCage недоступен / таймаут / квота (402/403/429): `503` `{error: "geo_unavailable"}`**не** 500
### `POST /v1/geo/reverse`
Тело: `{ "lat": 55.75, "lon": 37.62, "lang": "ru" }`. Подпись пина после drag.
Тот же shape, что geocode (`source: "opencage"`). Нет хита → `200` с `address` из округлённых координат или короткий fallback «lat, lon», без падения пикера. `503` только если OpenCage недоступен.
### Degrade
- Geo-эндпоинты: `503` / пустой suggest, не 500 на весь API.
- Create/update события и `default_location`: **не** зависят от геокодера. Текстовый адрес сохраняется.
- IFT/stage без ключа OpenCage: сервис живой; e2e пикера пропускают или проверяют degrade (текстовое поле).
## Ops (контракт DevOps#16)
Не API-ключи Google/Яндекс.
- Своего контейнера геокодера нет: бэкенд ходит во внешний OpenCage API (`https://api.opencagedata.com/geocode/v1/json`).
- Env бэкенда: `OPENCAGE_API_KEY` (секрет, только в `.env` стендов), `OPENCAGE_TIMEOUT_MS`, опционально `OPENCAGE_COUNTRYCODE`, `OPENCAGE_THROTTLE_RPS` (1 rps под free-тариф). Нет map API keys.
- Free-тариф OpenCage: 2500 запросов/сутки, 1 rps; 402/403/429 апстрима → `503 geo_unavailable`. ToS разрешает хранение результатов.
- CSP (Traefik / Front): тайлы OpenFreeMap, не Google/Yandex script.
Ориентир CSP (уточнить по фактическому style URL):
```
connect-src 'self' https://tiles.openfreemap.org
img-src 'self' data: blob: https://tiles.openfreemap.org
font-src 'self' data: https://fonts.gstatic.com https://tiles.openfreemap.org
worker-src 'self' blob:
```
Geo — same-origin `/v1/geo`. IFT/stage: ключ OpenCage в `.env` стенда; без ключа — degrade, не падение EventHub.
**Stage:** ключ OpenCage в `.env` (внешний сервис — без нагрузки на VPS). Пикер адреса работает на всех стендах с ключом; без ключа — текстовый адрес / пин. GPS «рядом» работает без геокодера.
## Front (контракт Front#74)
Библиотека: MapLibre GL JS. Стиль OpenFreeMap (например Liberty). Ключей в репозитории Front нет.
### Пикер
Форма события и org `default_location` (не сырые lat/lon как основной путь):
1. Поле адреса + suggest (`GET /v1/geo/suggest`).
2. Выбор хита → подставить address/lat/lon.
3. Мини-карта, перетаскиваемый пин → `POST /v1/geo/reverse`.
4. Ручной lat/lon можно спрятать (advanced), не убирать из контракта API.
OpenCage `503`: тост/подсказка, карта и ручной адрес остаются.
### Карточка / просмотр
Если есть валидные `lat`/`lon`:
- мини-карта MapLibre с пином и атрибуцией OSM;
- **рядом** ссылки (`target=_blank`, `rel="noopener noreferrer"`):
- Google Maps: `https://www.google.com/maps?q={lat},{lon}`
- Яндекс.Карты: `https://yandex.ru/maps/?pt={lon},{lat}&z=16` (порядок **lon,lat**)
- OpenStreetMap: `https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}`
Только адрес без координат: текст адреса, без карты и без deep-link.
i18n ru/en.
### Discover — ближайшие
Не map-first: тот же список `/search`. Ручной ввод lat/lon **не** основной путь.
**Центр поиска** (нужны оба `lat` и `lon`):
1. «Рядом со мной» — `navigator.geolocation` (гость и логин). Отказ/ошибка — без падения; подсказка выбрать адрес.
2. «Искать у адреса» — suggest (`GET /v1/geo/suggest`) → lat/lon центра. Не GPS.
**Радиус опционален** (км, UI-пресеты **1 / 5 / 10 / 25 / 50**):
| Радиус | Query | Смысл |
|--------|--------|--------|
| не выбран / «без ограничения» | `lat`,`lon` без `radius` | все точки с координатами, **сортировка по dist** |
| выбран пресет | `lat`,`lon`,`radius` | только ≤ N км, сортировка по dist |
Дефолт после «рядом со мной»: **10 км** (как сейчас на бэке), пользователь может сменить или снять лимит. `radius` без пары lat/lon игнорируется.
Фильтр `type` (`calendar` / `event` / оба) работает как сейчас. `q`/даты/теги сочетаются с geo.
В карточке результата — дистанция (`distance_km` с бэка), не сырые координаты.
## Ближайшие календари и события (контракт Back#77)
Тот же `GET /v1/search`. Geo-режим: есть `lat` и `lon`**не** discovery-tops.
### Что сравниваем
| Тип | Точка | Нет координат |
|-----|--------|----------------|
| `event` | `event.location.lat/lon` | вне geo-выборки |
| `calendar` | `settings.default_location.lat/lon` | вне geo-выборки (сейчас календари geo **не** фильтруются — это дыра волны 2) |
Personal по-прежнему не в search. ACL `can_access` без изменений. Restricted commercial — как в обычном search.
Формула: haversine, км (уже есть `logic_search:distance/4`).
### `radius`
- Задан: integer **1..100** км; вне диапазона → `400`. Фильтр `distance ≤ radius`.
- Не задан: **без отсечения** по dist; только сущности с валидными координатами.
- Default бэка `radius=10`, если клиент прислал lat/lon **без** ключа radius — **менять:** отсутствие ключа = без лимита. Front при «рядом» **явно** шлёт `radius=10` (или выбранный пресет).
### Сортировка
Geo-режим, клиент не передал `sort`**`distance` asc**. Явный `sort=title|created_at|start_time` — как сейчас, но только внутри уже отфильтрованных по радиусу.
Новое значение: `sort=distance` (км по возрастанию; `order=desc` — дальние первыми, редко нужно).
### Ответ
В каждом элементе `events[]` / `calendars[]` при geo-запросе:
```json
"distance_km": 1.4
```
Округление 1 знак. Без geo-параметров поле **нет** (не `null`).
Календари в geo-выдаче могут отдавать краткий `default_location` (`address`, `lat`, `lon`) — чтобы Front не гадал.
Индекс/PostGIS — **не** волна 2: тот же in-memory scan, что сейчас.
## Non-goals волны 2
- Turn-by-turn, пробки, трекинг курьеров/сотрудников.
- Map-first Discover / каталог пинами на всю выдачу.
- Свой tile-server / self-host OpenFreeMap.
- Публичный Nominatim или `photon.komoot.io` как fallback (чтобы не сесть на чужой fair-use).
- Google/Yandex JS SDK, ключи, Places Autocomplete.
- Пакетная геокодировка существующих событий без координат (можно later).
## Критерии приёмки канона
- [x] Этот документ согласован (Spec#21, вариант Ц).
- [ ] DevOps#16: ключ OpenCage в `.env` стендов, CSP OpenFreeMap; без Google/Yandex keys.
- [ ] Back#77: `/v1/geo/*`, кэш, address-only location; search: geo по календарям, optional radius, `sort=distance`, `distance_km`; тесты (mock OpenCage).
- [ ] Front#74: пикер + мини-карта + outbound Google/Яндекс/OSM; Discover ближайшие (GPS/адрес, пресеты радиуса); e2e/mock.
- [ ] После тестов: BackSpec/FrontSpec сверены с кодом.