feat(geo): auto-derive geometry из data.lat/lon + backfill migration
Записи 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-фильтр заработает.
This commit is contained in:
+35
@@ -207,6 +207,11 @@ public class CuodBundleImporter {
|
|||||||
}
|
}
|
||||||
DictionaryRecord record = new DictionaryRecord(
|
DictionaryRecord record = new DictionaryRecord(
|
||||||
UUID.randomUUID(), definition.getId(), businessKey, data, definition.getScope(), now);
|
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);
|
DictionaryRecord savedRecord = recordRepository.save(record);
|
||||||
|
|
||||||
var recEvent = new DictionaryRecordCreated(
|
var recEvent = new DictionaryRecordCreated(
|
||||||
@@ -228,4 +233,34 @@ public class CuodBundleImporter {
|
|||||||
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(DataScope d) {
|
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(DataScope d) {
|
||||||
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Backfill geometry для existing записей где data содержит lat/lon
|
||||||
|
(или latitude/longitude или lng), но geometry IS NULL. Match'ит
|
||||||
|
backend auto-derive в DictionaryRecordService.resolveGeometry +
|
||||||
|
CuodBundleImporter.deriveLatLonGeometry — bundle import skip'ит
|
||||||
|
existing records, поэтому уже-загруженные записи остались без
|
||||||
|
geometry. Без этой миграции AOI spatial filter возвращает 0 records
|
||||||
|
на ground_station и similar dictionaries.
|
||||||
|
|
||||||
|
Idempotent: только WHERE geometry IS NULL. Permission to fail per row
|
||||||
|
ON_ERROR=CONTINUE — некоторые записи могут иметь lat/lon как string
|
||||||
|
или вне диапазона; пропускаем, не падаем на всю миграцию.
|
||||||
|
|
||||||
|
Rollback: SET geometry = NULL для тех же rows (lossless если
|
||||||
|
geometry была null до).
|
||||||
|
-->
|
||||||
|
<changeSet id="0015-backfill-latlon-geometry-lat-lon" author="ordinis">
|
||||||
|
<comment>Backfill geometry из data.lat + data.lon</comment>
|
||||||
|
<sql>
|
||||||
|
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;
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>
|
||||||
|
UPDATE dictionary_records
|
||||||
|
SET geometry = NULL
|
||||||
|
WHERE jsonb_typeof(data->'lat') = 'number'
|
||||||
|
AND jsonb_typeof(data->'lon') = 'number';
|
||||||
|
</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0015-backfill-latlon-geometry-latitude-longitude" author="ordinis">
|
||||||
|
<comment>Backfill geometry из data.latitude + data.longitude (alias)</comment>
|
||||||
|
<sql>
|
||||||
|
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;
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>
|
||||||
|
UPDATE dictionary_records
|
||||||
|
SET geometry = NULL
|
||||||
|
WHERE jsonb_typeof(data->'latitude') = 'number'
|
||||||
|
AND jsonb_typeof(data->'longitude') = 'number';
|
||||||
|
</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0015-backfill-latlon-geometry-lat-lng" author="ordinis">
|
||||||
|
<comment>Backfill geometry из data.lat + data.lng (alternate alias)</comment>
|
||||||
|
<sql>
|
||||||
|
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;
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>
|
||||||
|
UPDATE dictionary_records
|
||||||
|
SET geometry = NULL
|
||||||
|
WHERE jsonb_typeof(data->'lat') = 'number'
|
||||||
|
AND jsonb_typeof(data->'lng') = 'number';
|
||||||
|
</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -24,5 +24,6 @@
|
|||||||
<include file="changes/0012-audit-log-ipaddress-varchar.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0012-audit-log-ipaddress-varchar.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0013-webhook-subscriptions.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0013-webhook-subscriptions.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0014-webhook-deliveries.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0014-webhook-deliveries.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0015-backfill-latlon-geometry.xml" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+43
-1
@@ -106,7 +106,7 @@ public class DictionaryRecordService {
|
|||||||
d.getScope(),
|
d.getScope(),
|
||||||
validFrom);
|
validFrom);
|
||||||
record.setValidTo(validTo);
|
record.setValidTo(validTo);
|
||||||
record.setGeometry(parseWkt(req.geometryWkt()));
|
record.setGeometry(resolveGeometry(req));
|
||||||
|
|
||||||
DictionaryRecord saved;
|
DictionaryRecord saved;
|
||||||
try {
|
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.
|
||||||
|
*
|
||||||
|
* <p>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) {
|
private void publishCreated(DictionaryDefinition d, DictionaryRecord r) {
|
||||||
String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
|
String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
|
||||||
var payload = new DictionaryRecordCreated(
|
var payload = new DictionaryRecordCreated(
|
||||||
|
|||||||
Reference in New Issue
Block a user