feat(geo): bbox spatial filter on GET /{dict}/records

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.
This commit is contained in:
Zimin A.N.
2026-05-06 15:43:26 +03:00
parent 75967edc9a
commit 0811ad8506
6 changed files with 256 additions and 4 deletions
@@ -85,4 +85,38 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
List<Object[]> countActiveGroupedByDictionary(
@Param("allowedScopes") Collection<DataScope> 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.
*
* <p>Native query (не JPA) потому что hibernate-spatial 7.x не имеет clean
* mapping для {@code ST_MakeEnvelope}. Native + ST_MakeEnvelope экономит
* один parse vs передачи WKT строки.
*
* <p>{@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<DictionaryRecord> 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);
}
@@ -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<DataScope> 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<FlattenedRecordResponse> listActive(
String dictionaryName,
String acceptLanguage,
List<DataScope> 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<DictionaryRecord> records = recordRepository.findActiveByDictionaryAndScopes(
def.getId(), allowedScopes, when);
List<DictionaryRecord> 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());
@@ -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).
*
* <p>Координаты в SRID 4326 (WGS84): longitude, latitude. Альтум-задача
* (заказ съёмки) использует именно этот формат.
*
* <p>Validation:
* <ul>
* <li>4 числа, через запятую</li>
* <li>longitude ∈ [-180, 180]</li>
* <li>latitude ∈ [-90, 90]</li>
* <li>west ≤ east, south ≤ north (no anti-meridian crossing в v1)</li>
* </ul>
*/
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);
}
}
@@ -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<DataScope> 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); }
}
}
@@ -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<ErrorBody> onBadBbox(BadBboxException e) {
return body(HttpStatus.BAD_REQUEST, "bad_bbox", e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorBody> onGeneric(Exception e) {
log.error("Unhandled exception in read-api", e);
@@ -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);
}
}