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:
+26
-2
@@ -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());
|
||||
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user