feat: водоёмы в зоне поиска на карте (рекомендации профилей → геослой)

- GET /api/v1/water/{case_id}: полигоны водоёмов/болот OSM вокруг ТНП
  (Overpass, natural=water+wetland, кэш 72ч, circuit breaker от geo_service);
  бейдж-предупреждение по профилям случая (РАС/эпилепсия/не умеет плавать)
- geo_service: цепочка зеркал Overpass (maps.mail.ru → overpass-api.de):
  основной сервер имеет AAAA, на хостах без IPv6-маршрута httpx падал
  ConnectError'ом до IPv4-фолбэка; на CT108 фапало стабильно
- SearchMap: слой водоёмов под зонами (синие полигоны, болота пунктиром),
  попапы с площадью/дистанцией от ТНП; AnalysisResult грузит слой,
  показывает предупреждение профиля и счётчик водоёмов
- проверено на CT108: РАС-кейс (Минск) — 43 объекта, витя-кейс
  (Каменец-борисовская обл.) — 7 объектов, включая водохранилище Загацце
This commit is contained in:
2026-09-09 12:48:44 +03:00
parent 2d264a5779
commit 98e8e58023
5 changed files with 366 additions and 21 deletions
+29 -18
View File
@@ -27,7 +27,14 @@ class Zone(BaseModel):
# Cache configuration
CACHE_DIR = Path("/tmp/overpass_cache")
CACHE_TTL_HOURS = 24
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Порядок зеркал: основной сервер имеет AAAA, но на хостах без IPv6-маршрута
# (наш LXC в VPN-контуре) httpx падает на ConnectError ещё до IPv4-фолбэка.
# Поэтому основной — зеркало без AAAA (maps.mail.ru), overpass-api.de — резерв.
OVERPASS_URLS = [
"https://maps.mail.ru/osm/tools/overpass/api/interpreter",
"https://overpass-api.de/api/interpreter",
]
OVERPASS_URL = OVERPASS_URLS[0] # обратная совместимость для импортов
logger = logging.getLogger(__name__)
@@ -220,27 +227,31 @@ async def query_overpass(query: str) -> Dict:
if _overpass_circuit_open():
return {'elements': []}
# Query API
# Query API — по цепочке зеркал (первый успешный ответ кэшируется)
timeout = httpx.Timeout(30.0, connect=OVERPASS_CONNECT_TIMEOUT)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(
OVERPASS_URL,
data={'data': query},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
response.raise_for_status()
data = response.json()
for overpass_url in OVERPASS_URLS:
try:
response = await client.post(
overpass_url,
data={'data': query},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
response.raise_for_status()
data = response.json()
# Save to cache
save_to_cache(cache_key, data)
# Save to cache
save_to_cache(cache_key, data)
_reset_overpass_circuit()
return data
except Exception as e:
_record_overpass_failure()
# Return empty result on error
return {'elements': []}
_reset_overpass_circuit()
return data
except Exception as e:
logger.info("Overpass %s недоступен: %s", overpass_url, e)
continue
# Все зеркала недоступны
_record_overpass_failure()
# Return empty result on error
return {'elements': []}
def calculate_road_length(elements: List[Dict]) -> float: