feat(geo): polygon GeoJSON spatial filter

Дополнение к bbox: ?polygon=<GeoJSON> для произвольных полигонов
(ground stations coverage, custom AOI). Один из bbox/polygon —
взаимоисключающие; передача обоих → 400.

Repository:
- findActiveByPolygonGeoJson native query через PostGIS
  ST_GeomFromGeoJSON(polygon) → ST_SetSRID(_, 4326). DB-side parsing,
  не нужен jts-io-common.

GeoJsonPolygon validator (app-side, structural):
- Парсит JSON, проверяет {"type":"Polygon","coordinates":[[...]]}
- RFC 7946 closed-ring check: first point == last
- >= 4 points в каждом ring (3 вертекса + closing point)
- Поддержка holes (multiple rings)
- Coordinate range validation: lon ∈ [-180,180], lat ∈ [-90,90]
- DoS guards: MAX_JSON_BYTES = 1MB, MAX_POINTS = 50_000
- Canonical re-serialize (whitespace stripped) перед DB

Tests (13 в GeoJsonPolygonTest):
- Valid square + polygon with hole
- Reject empty/null/non-JSON/non-object/wrong-type/missing-coords
- Reject too-few points / unclosed ring
- Reject lon/lat out of range
- Reject malformed point [no lat]
- Reject too-large JSON

Total project tests: 161 → 174.

API now supports:
  GET /api/v1/{dict}/records?bbox=west,south,east,north
  GET /api/v1/{dict}/records?polygon={"type":"Polygon",...}

Альтум-задача (rect AOI) и геопортал (polygon AOI) разблокированы
от client-side full-fetch + filter.
This commit is contained in:
Zimin A.N.
2026-05-06 15:47:04 +03:00
parent 0811ad8506
commit ac84780a95
5 changed files with 311 additions and 6 deletions
@@ -119,4 +119,31 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("south") double south,
@Param("east") double east,
@Param("north") double north);
/**
* Spatial filter по произвольному polygon в формате GeoJSON. PostGIS
* {@code ST_GeomFromGeoJSON} парсит на DB-side, ST_SetSRID вешает 4326
* (GeoJSON по spec не имеет SRID, нужен explicit cast). GiST index
* на geometry колонке используется для ST_Intersects.
*
* <p>Если GeoJSON malformed, Postgres вернёт SQL error → Hibernate
* выбросит DataAccessException → перехватывается в exception handler
* как 400 Bad Request.
*/
@Query(value = """
SELECT * FROM dictionary_records r
WHERE r.dictionary_id = :dictionaryId
AND r.data_scope = ANY(CAST(:scopesCsv AS TEXT[]))
AND r.valid_from <= :at
AND r.valid_to > :at
AND r.geometry IS NOT NULL
AND ST_Intersects(
r.geometry,
ST_SetSRID(ST_GeomFromGeoJSON(:polygonGeoJson), 4326))
""", nativeQuery = true)
List<DictionaryRecord> findActiveByPolygonGeoJson(
@Param("dictionaryId") UUID dictionaryId,
@Param("scopesCsv") String[] scopesCsv,
@Param("at") OffsetDateTime at,
@Param("polygonGeoJson") String polygonGeoJson);
}