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
This commit is contained in:
2026-08-18 23:30:23 +03:00
parent 588a282b6d
commit b79fd81260
3 changed files with 23 additions and 23 deletions
+3 -3
View File
@@ -271,7 +271,7 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
cold = gzip-файлы. Контракт: [`ARCHIVE.md`](ARCHIVE.md). Код: Back#76. cold = gzip-файлы. Контракт: [`ARCHIVE.md`](ARCHIVE.md). Код: Back#76.
#### Фаза 3 / гео (Spec#19, канон Spec#21) #### Фаза 3 / гео (Spec#19, канон Spec#21)
- Стек: свой Photon + MapLibre/OpenFreeMap; Google/Яндекс **не** провайдеры - Стек: OpenCage (внешний API, прокси через бэк) + MapLibre/OpenFreeMap; Google/Яндекс **не** провайдеры
(только outbound-ссылки с карточки). Контракт: [`GEO.md`](GEO.md). Код: Back#77. (только outbound-ссылки с карточки). Контракт: [`GEO.md`](GEO.md). Код: Back#77.
### 2.1.3. Share / приглашения (соредактор и заместитель) ### 2.1.3. Share / приглашения (соредактор и заместитель)
@@ -625,9 +625,9 @@ src/
**не** входит в контракт: поле URL. **не** входит в контракт: поле URL.
Гео: `lat`+`lon` (и опциональный `radius` 1..100) — ближайшие события и календари; Гео: `lat`+`lon` (и опциональный `radius` 1..100) — ближайшие события и календари;
`sort=distance`; в элементах `distance_km` ([GEO.md](GEO.md)). `sort=distance`; в элементах `distance_km` ([GEO.md](GEO.md)).
- `GET /v1/geo/suggest` — typeahead адреса (Photon); **без токена** + IP rate-limit. - `GET /v1/geo/suggest` — typeahead адреса (OpenCage); **без токена** + IP rate-limit.
- `POST /v1/geo/geocode``{q, lang?}``{address, lat, lon, source}` (Bearer). - `POST /v1/geo/geocode``{q, lang?}``{address, lat, lon, source}` (Bearer).
- `POST /v1/geo/reverse``{lat, lon, lang?}` (Bearer). Photon down`503` `geo_unavailable`. - `POST /v1/geo/reverse``{lat, lon, lang?}` (Bearer). OpenCage недоступен/квота`503` `geo_unavailable`.
- `GET /v1/calendars` — список календарей (auth): owned shared (`role`, `share_rights`, - `GET /v1/calendars` — список календарей (auth): owned shared (`role`, `share_rights`,
`mirror_to_default`). `mirror_to_default`).
- `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial; - `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial;
+1 -1
View File
@@ -412,7 +412,7 @@ cold = файлы. UI истории — React, не iframe. Код: Back#76, Fr
## 7.2. Фаза 3 — гео (Spec#19 / Spec#21) ## 7.2. Фаза 3 — гео (Spec#19 / Spec#21)
Канон: [`GEO.md`](GEO.md). Стек: MapLibre + OpenFreeMap + свой Photon. Канон: [`GEO.md`](GEO.md). Стек: MapLibre + OpenFreeMap + OpenCage (внешний геокодер через бэк).
Пикер адреса/пина; карточка с мини-картой и outbound Google/Яндекс/OSM; Пикер адреса/пина; карточка с мини-картой и outbound Google/Яндекс/OSM;
Discover ближайшие с опциональным радиусом. Код: Back#77, Front#74, DevOps#16. Discover ближайшие с опциональным радиусом. Код: Back#77, Front#74, DevOps#16.
+19 -19
View File
@@ -19,7 +19,7 @@
**Не используем:** Google Maps JavaScript API, Geocoding/Places, Яндекс JS API / Геокодер, ключи карт. **Не используем:** Google Maps JavaScript API, Geocoding/Places, Яндекс JS API / Геокодер, ключи карт.
**Разрешено:** outbound-ссылки на Google Maps и Яндекс.Карты с **нашими** координатами (Photon или пин пользователя). Это не вызов их API и не хранение их ответа. **Разрешено:** outbound-ссылки на Google Maps и Яндекс.Карты с **нашими** координатами (OpenCage или пин пользователя). Это не вызов их API и не хранение их ответа.
## Стек (вариант Ц) ## Стек (вариант Ц)
@@ -27,18 +27,18 @@
|------|--------|----------| |------|--------|----------|
| Карта в браузере | [MapLibre GL JS](https://maplibre.org/) | Leaflet + растр `tile.openstreetmap.org` (политика тайлов OSMF) | | Карта в браузере | [MapLibre GL JS](https://maplibre.org/) | Leaflet + растр `tile.openstreetmap.org` (политика тайлов OSMF) |
| Тайлы | [OpenFreeMap](https://openfreemap.org/) публичный инстанс, без ключа | Carto/MapTiler Free (не для коммерции EventHub); свой tile-server | | Тайлы | [OpenFreeMap](https://openfreemap.org/) публичный инстанс, без ключа | Carto/MapTiler Free (не для коммерции EventHub); свой tile-server |
| Геокод | **свой Photon** (Komoot), прокси только через EventHubBack | публичный Nominatim OSMF (1 rps, autocomplete запрещён); `photon.komoot.io` | | Геокод | **[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, сырой ответ Яндекса | | Хранение | `address` + `lat`/`lon` в EventHub | `place_id` Google, сырой ответ Яндекса |
Данные OSM — [ODbL](https://www.openstreetmap.org/copyright). На карте видимая атрибуция: «© OpenStreetMap contributors» со ссылкой на copyright. Не выгружаем базу OSM оптом через Photon. ToS OpenCage разрешает бессрочно хранить результаты геокодирования — кэш и сохранённые координаты события легальны. Данные OSM — [ODbL](https://www.openstreetmap.org/copyright). На карте видимая атрибуция: «© OpenStreetMap contributors» со ссылкой на copyright.
``` ```
Front (MapLibre) --tiles--> OpenFreeMap Front (MapLibre) --tiles--> OpenFreeMap
Front --suggest/geocode/reverse--> Back /v1/geo --> Photon (self-host) Front --suggest/geocode/reverse--> Back /v1/geo --> OpenCage API (ключ в env бэка)
Back --persist--> event.location / calendar.settings.default_location Back --persist--> event.location / calendar.settings.default_location
``` ```
Front **не** ходит в Photon и Nominatim напрямую. Front **не** ходит в OpenCage и Nominatim напрямую (ключ не должен попасть в браузер).
## Модель данных (уже есть) ## Модель данных (уже есть)
@@ -53,7 +53,7 @@ Front **не** ходит в Photon и Nominatim напрямую.
## API геокодирования (контракт Back#77) ## API геокодирования (контракт Back#77)
`POST` geocode/reverse: user Bearer (пикер). `GET /v1/geo/suggest`: **без токена** (Discover «центр по адресу» для гостя) + rate-limit по IP; с Bearer — тот же лимит на user. Photon с браузера не светим. `POST` geocode/reverse: user Bearer (пикер). `GET /v1/geo/suggest`: **без токена** (Discover «центр по адресу» для гостя) + rate-limit по IP; с Bearer — тот же лимит на user. Ключ OpenCage с браузера не светим.
Кэш одинаковых `q`+`lang` (ETS/Mnesia, TTL дни). Таймаут апстрима ~3 s. Кэш одинаковых `q`+`lang` (ETS/Mnesia, TTL дни). Таймаут апстрима ~3 s.
@@ -77,29 +77,29 @@ Typeahead. Клиент debounce (≥300 ms). Пустой / слишком ко
Тело: `{ "q": "…", "lang": "ru" }` (`lang` опционален, иначе язык пользователя / `ru`). Тело: `{ "q": "…", "lang": "ru" }` (`lang` опционален, иначе язык пользователя / `ru`).
- `200`: `{ "address": "…", "lat": 55.75, "lon": 37.62, "source": "photon" }` - `200`: `{ "address": "…", "lat": 55.75, "lon": 37.62, "source": "opencage" }`
- нет хита: `404` `{error: "not_found"}` - нет хита: `404` `{error: "not_found"}`
- Photon недоступен / таймаут: `503` `{error: "geo_unavailable"}`**не** 500 - OpenCage недоступен / таймаут / квота (402/403/429): `503` `{error: "geo_unavailable"}`**не** 500
### `POST /v1/geo/reverse` ### `POST /v1/geo/reverse`
Тело: `{ "lat": 55.75, "lon": 37.62, "lang": "ru" }`. Подпись пина после drag. Тело: `{ "lat": 55.75, "lon": 37.62, "lang": "ru" }`. Подпись пина после drag.
Тот же shape, что geocode (`source: "photon"`). Нет хита → `200` с `address` из округлённых координат или короткий fallback «lat, lon», без падения пикера. `503` только если Photon недоступен. Тот же shape, что geocode (`source: "opencage"`). Нет хита → `200` с `address` из округлённых координат или короткий fallback «lat, lon», без падения пикера. `503` только если OpenCage недоступен.
### Degrade ### Degrade
- Geo-эндпоинты: `503` / пустой suggest, не 500 на весь API. - Geo-эндпоинты: `503` / пустой suggest, не 500 на весь API.
- Create/update события и `default_location`: **не** зависят от Photon. Текстовый адрес сохраняется. - Create/update события и `default_location`: **не** зависят от геокодера. Текстовый адрес сохраняется.
- IFT/stage без Photon: сервис живой; e2e пикера пропускают или проверяют degrade (текстовое поле). - IFT/stage без ключа OpenCage: сервис живой; e2e пикера пропускают или проверяют degrade (текстовое поле).
## Ops (контракт DevOps#16) ## Ops (контракт DevOps#16)
Не API-ключи Google/Яндекс. Не API-ключи Google/Яндекс.
- Compose-сервис **Photon** (JAR + OpenSearch) + volume индекса. Не публиковать Photon в интернет; только сеть compose → EventHubBack. - Своего контейнера геокодера нет: бэкенд ходит во внешний OpenCage API (`https://api.opencagedata.com/geocode/v1/json`).
- Env бэкенда: `PHOTON_URL` (например `http://photon:2322`), `PHOTON_TIMEOUT_MS`. Нет map API keys. - Env бэкенда: `OPENCAGE_API_KEY` (секрет, только в `.env` стендов), `OPENCAGE_TIMEOUT_MS`, опционально `OPENCAGE_COUNTRYCODE`, `OPENCAGE_THROTTLE_RPS` (1 rps под free-тариф). Нет map API keys.
- Выгрузка OSM: **минимум РФ** (+ СНГ/крупные города, если диск позволяет). Planet — если влезает. Обновление индекса — периодический job, не realtime. - Free-тариф OpenCage: 2500 запросов/сутки, 1 rps; 402/403/429 апстрима → `503 geo_unavailable`. ToS разрешает хранение результатов.
- CSP (Traefik / Front): тайлы OpenFreeMap, не Google/Yandex script. - CSP (Traefik / Front): тайлы OpenFreeMap, не Google/Yandex script.
Ориентир CSP (уточнить по фактическому style URL): Ориентир CSP (уточнить по фактическому style URL):
@@ -111,9 +111,9 @@ font-src 'self' data: https://fonts.gstatic.com https://tiles.openfreemap.org
worker-src 'self' blob: worker-src 'self' blob:
``` ```
Geo — same-origin `/v1/geo`. IFT/stage: Photon в compose; без контейнера — degrade, не падение EventHub. Geo — same-origin `/v1/geo`. IFT/stage: ключ OpenCage в `.env` стенда; без ключа — degrade, не падение EventHub.
**Stage:** свой Photon не запускаем (`PHOTON_URL` пустой). Не используем `photon.komoot.io` как апстрим. Пикер адреса — на IFT при включённом Photon; на stage — текстовый адрес / пин. GPS «рядом» работает без геокодера. **Stage:** ключ OpenCage в `.env` (внешний сервис — без нагрузки на VPS). Пикер адреса работает на всех стендах с ключом; без ключа — текстовый адрес / пин. GPS «рядом» работает без геокодера.
## Front (контракт Front#74) ## Front (контракт Front#74)
@@ -128,7 +128,7 @@ Geo — same-origin `/v1/geo`. IFT/stage: Photon в compose; без контей
3. Мини-карта, перетаскиваемый пин → `POST /v1/geo/reverse`. 3. Мини-карта, перетаскиваемый пин → `POST /v1/geo/reverse`.
4. Ручной lat/lon можно спрятать (advanced), не убирать из контракта API. 4. Ручной lat/lon можно спрятать (advanced), не убирать из контракта API.
Photon `503`: тост/подсказка, карта и ручной адрес остаются. OpenCage `503`: тост/подсказка, карта и ручной адрес остаются.
### Карточка / просмотр ### Карточка / просмотр
@@ -219,7 +219,7 @@ Geo-режим, клиент не передал `sort` → **`distance` asc**.
## Критерии приёмки канона ## Критерии приёмки канона
- [x] Этот документ согласован (Spec#21, вариант Ц). - [x] Этот документ согласован (Spec#21, вариант Ц).
- [ ] DevOps#16: Photon в compose, `PHOTON_URL`, CSP OpenFreeMap; без Google/Yandex keys. - [ ] 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 Photon). - [ ] 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. - [ ] Front#74: пикер + мини-карта + outbound Google/Яндекс/OSM; Discover ближайшие (GPS/адрес, пресеты радиуса); e2e/mock.
- [ ] После тестов: BackSpec/FrontSpec сверены с кодом. - [ ] После тестов: BackSpec/FrontSpec сверены с кодом.