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:
Zimin A.N.
2026-05-06 22:39:45 +03:00
parent c0444ccfec
commit 9214ee931b
4 changed files with 178 additions and 1 deletions
@@ -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.
*
* <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) {
String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
var payload = new DictionaryRecordCreated(