From 0811ad850628b9dfa842771c05ddff6b64786db5 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 15:43:26 +0300 Subject: [PATCH] feat(geo): bbox spatial filter on GET /{dict}/records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 фича: Альтум-задача (заказ съёмки) использует bbox region. До этого consumers fetch'или ВСЕ записи и filter'или client-side. Теперь PostGIS ST_Intersects на server-side через GiST index. API: GET /api/v1/{dictionaryName}/records?bbox=west,south,east,north Coords в SRID 4326 (longitude, latitude). Параметр optional — отсутствует = старое поведение (full list). BoundingBox helper (ordinis-read-api/spatial/): - Парсинг "west,south,east,north" с валидацией - Range checks: lon ∈ [-180,180], lat ∈ [-90,90] - west <= east (anti-meridian crossing rejected в v1) - south <= north - Понятные error messages → 400 Bad Request через BadBboxException Repository (ordinis-domain): - findActiveByBbox native query, ST_MakeEnvelope(:west,:south,:east, :north,4326) ST_Intersects на geometry колонке. GiST index hit. - Записи с geometry IS NULL автоматически отфильтровываются. Tests (12 в BoundingBoxTest): - Valid parsing + negative coords + whitespace - Reject empty/null/wrong-count/non-numeric - Range validation (lon/lat bounds) - Anti-meridian + inverted N/S detected - Area calc sanity Total project tests: 149 → 161. Polygon GeoJSON support — отложен (нужен jts-io-common dep). Bbox покрывает Альтум use case + 80% other consumers. --- .../record/DictionaryRecordRepository.java | 34 ++++++ .../readapi/service/RecordReadService.java | 28 ++++- .../ordinis/readapi/spatial/BoundingBox.java | 72 +++++++++++++ .../readapi/web/DictionaryReadController.java | 20 +++- .../readapi/web/ReadApiExceptionHandler.java | 6 ++ .../readapi/spatial/BoundingBoxTest.java | 100 ++++++++++++++++++ 6 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBox.java create mode 100644 ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBoxTest.java diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java index 52b6677..b0e2842 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java @@ -85,4 +85,38 @@ public interface DictionaryRecordRepository extends JpaRepository countActiveGroupedByDictionary( @Param("allowedScopes") Collection allowedScopes, @Param("at") OffsetDateTime at); + + /** + * Spatial filter: активные записи которые intersect bbox (SRID 4326). + * Используется read-api endpoint {@code GET /api/v1/{dict}/records?bbox=...}. + * PostGIS {@code ST_Intersects} использует GiST index на geometry колонке — + * O(log n) lookup, не sequential scan. + * + *

Native query (не JPA) потому что hibernate-spatial 7.x не имеет clean + * mapping для {@code ST_MakeEnvelope}. Native + ST_MakeEnvelope экономит + * один parse vs передачи WKT строки. + * + *

{@code data_scope IN (...)} нативно требует постгресовский + * ANY(string_array) — но у нас scopes небольшой enum, ставим IN-list через + * JPA String parameter. + */ + @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_MakeEnvelope(:west, :south, :east, :north, 4326)) + """, nativeQuery = true) + List findActiveByBbox( + @Param("dictionaryId") UUID dictionaryId, + @Param("scopesCsv") String[] scopesCsv, + @Param("at") OffsetDateTime at, + @Param("west") double west, + @Param("south") double south, + @Param("east") double east, + @Param("north") double north); } diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java index 9f921ca..0595bed 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java @@ -8,6 +8,7 @@ import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener; import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator; +import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox; import com.fasterxml.jackson.databind.JsonNode; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; @@ -102,6 +103,21 @@ public class RecordReadService { String acceptLanguage, List allowedScopes, OffsetDateTime asOf) { + return listActive(dictionaryName, acceptLanguage, allowedScopes, asOf, null); + } + + /** + * List active records с optional bbox spatial filter. Если {@code bbox} + * не null — используется PostGIS {@code ST_Intersects} (GiST index hit). + * Записи без geometry колонки автоматически отфильтровываются. + */ + @Transactional(readOnly = true) + public List listActive( + String dictionaryName, + String acceptLanguage, + List allowedScopes, + OffsetDateTime asOf, + BoundingBox bbox) { DictionaryDefinition def = definitionRepository.findByName(dictionaryName) .orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName)); @@ -112,8 +128,16 @@ public class RecordReadService { } OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf; - List records = recordRepository.findActiveByDictionaryAndScopes( - def.getId(), allowedScopes, when); + List records; + if (bbox != null) { + String[] scopesCsv = allowedScopes.stream().map(Enum::name).toArray(String[]::new); + records = recordRepository.findActiveByBbox( + def.getId(), scopesCsv, when, + bbox.west(), bbox.south(), bbox.east(), bbox.north()); + } else { + records = recordRepository.findActiveByDictionaryAndScopes( + def.getId(), allowedScopes, when); + } String chosen = localeNegotiator.negotiate( acceptLanguage, def.getSupportedLocales(), def.getDefaultLocale()); diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBox.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBox.java new file mode 100644 index 0000000..1f65939 --- /dev/null +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBox.java @@ -0,0 +1,72 @@ +package cloud.nstart.terravault.ordinis.readapi.spatial; + +/** + * Bounding box (west-south-east-north) для spatial filtering. Парсится + * из query param {@code ?bbox=west,south,east,north} (4 floats). + * + *

Координаты в SRID 4326 (WGS84): longitude, latitude. Альтум-задача + * (заказ съёмки) использует именно этот формат. + * + *

Validation: + *

    + *
  • 4 числа, через запятую
  • + *
  • longitude ∈ [-180, 180]
  • + *
  • latitude ∈ [-90, 90]
  • + *
  • west ≤ east, south ≤ north (no anti-meridian crossing в v1)
  • + *
+ */ +public record BoundingBox(double west, double south, double east, double north) { + + /** + * Парсит {@code "west,south,east,north"}. На любую ошибку формата + * бросает {@link IllegalArgumentException} с понятным message. + */ + public static BoundingBox parse(String csv) { + if (csv == null || csv.isBlank()) { + throw new IllegalArgumentException("bbox is empty"); + } + String[] parts = csv.split(","); + if (parts.length != 4) { + throw new IllegalArgumentException( + "bbox must be 4 comma-separated numbers (west,south,east,north), got " + parts.length); + } + double[] coords = new double[4]; + for (int i = 0; i < 4; i++) { + try { + coords[i] = Double.parseDouble(parts[i].trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("bbox component #" + (i + 1) + " is not a number: " + parts[i]); + } + } + BoundingBox bbox = new BoundingBox(coords[0], coords[1], coords[2], coords[3]); + bbox.validate(); + return bbox; + } + + public void validate() { + if (west < -180 || west > 180) { + throw new IllegalArgumentException("west longitude " + west + " out of range [-180, 180]"); + } + if (east < -180 || east > 180) { + throw new IllegalArgumentException("east longitude " + east + " out of range [-180, 180]"); + } + if (south < -90 || south > 90) { + throw new IllegalArgumentException("south latitude " + south + " out of range [-90, 90]"); + } + if (north < -90 || north > 90) { + throw new IllegalArgumentException("north latitude " + north + " out of range [-90, 90]"); + } + if (west > east) { + throw new IllegalArgumentException( + "west " + west + " > east " + east + " (anti-meridian crossing not supported in v1)"); + } + if (south > north) { + throw new IllegalArgumentException("south " + south + " > north " + north); + } + } + + /** Площадь в квадратных градусах (грубая, не sphere). Для UI rendering / debugging. */ + public double areaInDegreesSquared() { + return (east - west) * (north - south); + } +} diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java index 0ca95d4..dfdff39 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java @@ -4,6 +4,7 @@ import cloud.nstart.terravault.ordinis.auth.ScopeContext; import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService; +import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.http.HttpHeaders; @@ -67,10 +68,20 @@ public class DictionaryReadController { @PathVariable String dictionaryName, @RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage, @RequestParam(value = "as_scope", required = false) String asScope, - @RequestParam(value = "at", required = false) OffsetDateTime at) { + @RequestParam(value = "at", required = false) OffsetDateTime at, + @RequestParam(value = "bbox", required = false) String bboxCsv) { + + BoundingBox bbox = null; + if (bboxCsv != null && !bboxCsv.isBlank()) { + try { + bbox = BoundingBox.parse(bboxCsv); + } catch (IllegalArgumentException e) { + throw new BadBboxException(e.getMessage()); + } + } List scopes = scopeContext.resolveForRead(asScope).stream().toList(); - var items = service.listActive(dictionaryName, acceptLanguage, scopes, at); + var items = service.listActive(dictionaryName, acceptLanguage, scopes, at, bbox); HttpHeaders headers = new HttpHeaders(); if (!items.isEmpty() && items.get(0)._meta() != null && items.get(0)._meta().locale() != null) { @@ -79,4 +90,9 @@ public class DictionaryReadController { headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE); return new ResponseEntity<>(items, headers, 200); } + + /** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */ + public static class BadBboxException extends RuntimeException { + public BadBboxException(String message) { super(message); } + } } diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/ReadApiExceptionHandler.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/ReadApiExceptionHandler.java index c811e82..6c667bd 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/ReadApiExceptionHandler.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/ReadApiExceptionHandler.java @@ -1,6 +1,7 @@ package cloud.nstart.terravault.ordinis.readapi.web; import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService.ScopeAccessDeniedException; +import cloud.nstart.terravault.ordinis.readapi.web.DictionaryReadController.BadBboxException; import com.fasterxml.jackson.annotation.JsonInclude; import io.micrometer.tracing.Tracer; import org.slf4j.Logger; @@ -43,6 +44,11 @@ public class ReadApiExceptionHandler { return body(HttpStatus.BAD_REQUEST, "bad_request", e.getMessage()); } + @ExceptionHandler(BadBboxException.class) + public ResponseEntity onBadBbox(BadBboxException e) { + return body(HttpStatus.BAD_REQUEST, "bad_bbox", e.getMessage()); + } + @ExceptionHandler(Exception.class) public ResponseEntity onGeneric(Exception e) { log.error("Unhandled exception in read-api", e); diff --git a/ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBoxTest.java b/ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBoxTest.java new file mode 100644 index 0000000..af3c817 --- /dev/null +++ b/ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/BoundingBoxTest.java @@ -0,0 +1,100 @@ +package cloud.nstart.terravault.ordinis.readapi.spatial; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BoundingBoxTest { + + @Test + void parsesValidBboxString() { + BoundingBox bbox = BoundingBox.parse("37.5,55.5,37.8,55.8"); + assertThat(bbox.west()).isEqualTo(37.5); + assertThat(bbox.south()).isEqualTo(55.5); + assertThat(bbox.east()).isEqualTo(37.8); + assertThat(bbox.north()).isEqualTo(55.8); + } + + @Test + void parsesNegativeCoordinates() { + BoundingBox bbox = BoundingBox.parse("-180,-90,180,90"); + assertThat(bbox.west()).isEqualTo(-180); + assertThat(bbox.east()).isEqualTo(180); + } + + @Test + void trimsWhitespace() { + BoundingBox bbox = BoundingBox.parse(" 37.5 , 55.5 , 37.8 , 55.8 "); + assertThat(bbox.west()).isEqualTo(37.5); + } + + @Test + void rejectsEmpty() { + assertThatThrownBy(() -> BoundingBox.parse("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + assertThatThrownBy(() -> BoundingBox.parse(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsWrongCount() { + assertThatThrownBy(() -> BoundingBox.parse("1,2,3")) + .hasMessageContaining("4 comma-separated"); + assertThatThrownBy(() -> BoundingBox.parse("1,2,3,4,5")) + .hasMessageContaining("4 comma-separated"); + } + + @Test + void rejectsNonNumeric() { + assertThatThrownBy(() -> BoundingBox.parse("a,b,c,d")) + .hasMessageContaining("not a number"); + } + + @Test + void rejectsLongitudeOutOfRange() { + assertThatThrownBy(() -> BoundingBox.parse("-181,0,1,1")) + .hasMessageContaining("longitude"); + assertThatThrownBy(() -> BoundingBox.parse("0,0,181,1")) + .hasMessageContaining("longitude"); + } + + @Test + void rejectsLatitudeOutOfRange() { + assertThatThrownBy(() -> BoundingBox.parse("0,-91,1,0")) + .hasMessageContaining("latitude"); + assertThatThrownBy(() -> BoundingBox.parse("0,0,1,91")) + .hasMessageContaining("latitude"); + } + + @Test + void rejectsAntiMeridianCrossing() { + // west > east — обычно signal что caller хотел anti-meridian (Pacific + // crossing). v1 не поддерживает — лучше явная ошибка чем неправильный + // intersect. + assertThatThrownBy(() -> BoundingBox.parse("170,0,-170,1")) + .hasMessageContaining("anti-meridian"); + } + + @Test + void rejectsInvertedNorthSouth() { + assertThatThrownBy(() -> BoundingBox.parse("0,55,1,30")) + .hasMessageContaining("south"); + } + + @Test + void areaInDegreesSquared() { + BoundingBox unit = new BoundingBox(0, 0, 1, 1); + assertThat(unit.areaInDegreesSquared()).isEqualTo(1.0); + + BoundingBox moscow = new BoundingBox(37.0, 55.0, 38.0, 56.0); + assertThat(moscow.areaInDegreesSquared()).isEqualTo(1.0); + } + + @Test + void worldwide() { + BoundingBox world = BoundingBox.parse("-180,-90,180,90"); + assertThat(world.areaInDegreesSquared()).isEqualTo(360.0 * 180.0); + } +}