From 9214ee931b623c5aae4a26d7b3fe03631276a577 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 22:39:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(geo):=20auto-derive=20geometry=20=D0=B8?= =?UTF-8?q?=D0=B7=20data.lat/lon=20+=20backfill=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Записи ground_station и similar dicts имеют lat/lon в data, но geometryWkt не задан. Spatial filter (AOI) WHERE geometry IS NOT NULL AND ST_Intersects(...) возвращал 0 records даже когда координаты есть. Три места починены: 1. DictionaryRecordService.resolveGeometry — для CRUD через REST. Если req.geometryWkt() задан — приоритет (explicit). Иначе читаем data.lat/lon (или latitude/longitude/lng), валидируем ranges, строим POINT(lon lat) с SRID 4326. Игнор out-of-range без throw — не ломаем legacy data. 2. CuodBundleImporter.deriveLatLonGeometry — bundle bypass'ит service, reuse'аем ту же логику локально (5 строк inline). Применяется при создании НОВЫХ records (existing skipped по businessKey). 3. Liquibase migration 0015-backfill-latlon-geometry — UPDATE existing records где geometry IS NULL AND data has lat/lon. Три changeset'а для разных aliases (lat/lon, latitude/longitude, lat/lng). Idempotent (только NULL→value), rollback supported (NULL обратно). После deploy: existing 3 ground_station записей (Отрадное и др.) и spacecraft с lat/lon получат POINT geometry. AOI bbox-фильтр заработает. --- .../ordinis/cuod/CuodBundleImporter.java | 35 +++++++ .../changes/0015-backfill-latlon-geometry.xml | 99 +++++++++++++++++++ .../main/resources/db/changelog/master.xml | 1 + .../service/DictionaryRecordService.java | 44 ++++++++- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 ordinis-migrations/src/main/resources/db/changelog/changes/0015-backfill-latlon-geometry.xml diff --git a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java index b1dafe9..007a8c3 100644 --- a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java +++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java @@ -207,6 +207,11 @@ public class CuodBundleImporter { } DictionaryRecord record = new DictionaryRecord( UUID.randomUUID(), definition.getId(), businessKey, data, definition.getScope(), now); + // Auto-derive POINT geometry из data.lat/lon — match service behaviour. + // Без этого AOI spatial filter возвращает 0 records даже когда у dict + // есть записи с координатами. + org.locationtech.jts.geom.Geometry derived = deriveLatLonGeometry(data); + if (derived != null) record.setGeometry(derived); DictionaryRecord savedRecord = recordRepository.save(record); var recEvent = new DictionaryRecordCreated( @@ -228,4 +233,34 @@ public class CuodBundleImporter { private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(DataScope d) { return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name()); } + + /** + * Auto-derive POINT(lon lat) geometry из data.lat/lon. Mirror'ит logic в + * DictionaryRecordService.resolveGeometry — bundle importer bypass'ит + * service, поэтому ту же конвенцию реализуем тут отдельно. Match keys: + * lat|latitude, lon|longitude|lng. Validate ranges. + */ + private static org.locationtech.jts.geom.Geometry deriveLatLonGeometry(JsonNode data) { + if (data == null || data.isMissingNode()) return null; + Double lat = pickNumber(data, "lat", "latitude"); + Double lon = pickNumber(data, "lon", "longitude", "lng"); + if (lat == null || lon == null) return null; + if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) return null; + try { + org.locationtech.jts.geom.Geometry g = new org.locationtech.jts.io.WKTReader() + .read("POINT(" + lon + " " + lat + ")"); + g.setSRID(4326); + return g; + } catch (org.locationtech.jts.io.ParseException e) { + return null; + } + } + + private static Double pickNumber(JsonNode data, String... keys) { + for (String k : keys) { + JsonNode n = data.get(k); + if (n != null && n.isNumber()) return n.doubleValue(); + } + return null; + } } diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0015-backfill-latlon-geometry.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0015-backfill-latlon-geometry.xml new file mode 100644 index 0000000..04f2b62 --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0015-backfill-latlon-geometry.xml @@ -0,0 +1,99 @@ + + + + + + Backfill geometry из data.lat + data.lon + + UPDATE dictionary_records + SET geometry = ST_SetSRID( + ST_MakePoint( + (data->>'lon')::double precision, + (data->>'lat')::double precision), + 4326) + WHERE geometry IS NULL + AND jsonb_typeof(data->'lat') = 'number' + AND jsonb_typeof(data->'lon') = 'number' + AND (data->>'lat')::double precision BETWEEN -90 AND 90 + AND (data->>'lon')::double precision BETWEEN -180 AND 180; + + + + UPDATE dictionary_records + SET geometry = NULL + WHERE jsonb_typeof(data->'lat') = 'number' + AND jsonb_typeof(data->'lon') = 'number'; + + + + + + Backfill geometry из data.latitude + data.longitude (alias) + + UPDATE dictionary_records + SET geometry = ST_SetSRID( + ST_MakePoint( + (data->>'longitude')::double precision, + (data->>'latitude')::double precision), + 4326) + WHERE geometry IS NULL + AND jsonb_typeof(data->'latitude') = 'number' + AND jsonb_typeof(data->'longitude') = 'number' + AND (data->>'latitude')::double precision BETWEEN -90 AND 90 + AND (data->>'longitude')::double precision BETWEEN -180 AND 180; + + + + UPDATE dictionary_records + SET geometry = NULL + WHERE jsonb_typeof(data->'latitude') = 'number' + AND jsonb_typeof(data->'longitude') = 'number'; + + + + + + Backfill geometry из data.lat + data.lng (alternate alias) + + UPDATE dictionary_records + SET geometry = ST_SetSRID( + ST_MakePoint( + (data->>'lng')::double precision, + (data->>'lat')::double precision), + 4326) + WHERE geometry IS NULL + AND jsonb_typeof(data->'lat') = 'number' + AND jsonb_typeof(data->'lng') = 'number' + AND (data->>'lat')::double precision BETWEEN -90 AND 90 + AND (data->>'lng')::double precision BETWEEN -180 AND 180; + + + + UPDATE dictionary_records + SET geometry = NULL + WHERE jsonb_typeof(data->'lat') = 'number' + AND jsonb_typeof(data->'lng') = 'number'; + + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index 9bc6291..ddcd83c 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -24,5 +24,6 @@ + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java index 02cd11d..6d52aa6 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java @@ -106,7 +106,7 @@ public class DictionaryRecordService { d.getScope(), validFrom); record.setValidTo(validTo); - record.setGeometry(parseWkt(req.geometryWkt())); + record.setGeometry(resolveGeometry(req)); DictionaryRecord saved; try { @@ -314,6 +314,48 @@ public class DictionaryRecordService { } } + /** + * Resolve final geometry для записи: explicit geometryWkt из request имеет + * приоритет; если не задан — автодеривируется POINT из числовых полей + * data.lat/lon (или latitude/longitude). Это позволяет AOI spatial filter + * работать на справочниках типа ground_station/spacecraft без ручного + * дублирования lat/lon → WKT в каждом record. + * + *

Validation: lat ∈ [-90, 90], lon ∈ [-180, 180]. Если значения вне + * диапазона — игнорируем (не throw, чтобы не ломать legacy records со + * странными данными). Спорные значения validate'ятся schema-уровнем + * через {@code minimum/maximum}. + */ + private Geometry resolveGeometry(CreateRecordRequest req) { + if (req.geometryWkt() != null && !req.geometryWkt().isBlank()) { + return parseWkt(req.geometryWkt()); + } + var data = req.data(); + if (data == null) return null; + Double lat = pickLatLon(data, "lat", "latitude"); + Double lon = pickLatLon(data, "lon", "longitude", "lng"); + if (lat == null || lon == null) return null; + if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) return null; + String pointWkt = "POINT(" + lon + " " + lat + ")"; + try { + Geometry g = new WKTReader().read(pointWkt); + g.setSRID(4326); + return g; + } catch (ParseException e) { + // Не должно случиться (мы сами sсонструировали WKT) — но лучше null + // чем 500 если cosmic ray flip'нет double. + return null; + } + } + + private static Double pickLatLon(com.fasterxml.jackson.databind.JsonNode data, String... keys) { + for (String key : keys) { + var node = data.get(key); + if (node != null && node.isNumber()) return node.doubleValue(); + } + return null; + } + private void publishCreated(DictionaryDefinition d, DictionaryRecord r) { String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry()); var payload = new DictionaryRecordCreated(