feat(cuod-bundle): seed records auto-import on startup

CuodBundleImporter теперь после создания DictionaryDefinition подгружает
records/<name>.records.json (relative to bundle resourceRoot) и создаёт
DictionaryRecord для каждой записи. Идемпотентно: пропускает business_key,
который уже существует.

Bundle:
- 5 satellite_type (SAR_X_HIRES, OPT_HIRES_VIS, OPT_MIDRES_MS, HYPER, METEO_GEO)
- 4 spacecraft (Terra, Sentinel-1A, Sentinel-2A, Кондор-ФКА №2)
- 3 ground_station (Отрадное, SvalSat, Байконур)

Все с локализованными name/description (ru-RU + en-US), валидируемые
JSON Schema на pre-save. Outbox events RecordCreated отправляются для
projection-writer.
This commit is contained in:
Александр Зимин
2026-05-03 23:31:51 +03:00
parent 519c397a29
commit aab7199600
5 changed files with 391 additions and 1 deletions
@@ -24,5 +24,6 @@ public record BundleManifest(
String schemaResource, String schemaResource,
String schemaVersion, String schemaVersion,
List<String> supportedLocales, List<String> supportedLocales,
String defaultLocale) {} String defaultLocale,
String recordsResource) {}
} }
@@ -3,9 +3,12 @@ package cloud.nstart.terravault.ordinis.cuod;
import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.events.EventEnvelope; import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated; import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated; import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -19,6 +22,7 @@ import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -40,6 +44,7 @@ public class CuodBundleImporter {
private final CuodBundleProperties props; private final CuodBundleProperties props;
private final DictionaryDefinitionRepository repository; private final DictionaryDefinitionRepository repository;
private final DictionaryRecordRepository recordRepository;
private final OutboxRecorder outboxRecorder; private final OutboxRecorder outboxRecorder;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
@@ -47,10 +52,12 @@ public class CuodBundleImporter {
public CuodBundleImporter( public CuodBundleImporter(
CuodBundleProperties props, CuodBundleProperties props,
DictionaryDefinitionRepository repository, DictionaryDefinitionRepository repository,
DictionaryRecordRepository recordRepository,
OutboxRecorder outboxRecorder, OutboxRecorder outboxRecorder,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.props = props; this.props = props;
this.repository = repository; this.repository = repository;
this.recordRepository = recordRepository;
this.outboxRecorder = outboxRecorder; this.outboxRecorder = outboxRecorder;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -151,6 +158,72 @@ public class CuodBundleImporter {
EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event)); EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event));
log.info("Справочник '{}' создан (scope={}, locales={}, default={})", log.info("Справочник '{}' создан (scope={}, locales={}, default={})",
saved.getName(), saved.getScope(), entryLocales, entryDefault); saved.getName(), saved.getScope(), entryLocales, entryDefault);
importRecords(manifest, entry, saved);
}
/**
* Подгружает seed records из {@code records/<name>.records.json} (если файл задан в
* manifest через {@code recordsResource} или существует по конвенции). Идемпотентно:
* при повторном вызове не создаёт дубликатов — пропускает существующие businessKey.
*/
private void importRecords(BundleManifest manifest, BundleManifest.DictionaryEntry entry,
DictionaryDefinition definition) {
String recordsPath = entry.recordsResource() != null
? entry.recordsResource()
: "records/" + entry.name() + ".records.json";
Resource recordsResource = resolver.getResource(props.resourceRoot() + recordsPath);
if (!recordsResource.exists()) {
log.debug("Records file для '{}' не найден ({}), пропускаем seed", entry.name(), recordsPath);
return;
}
JsonNode recordsArray;
try (var in = recordsResource.getInputStream()) {
recordsArray = objectMapper.readTree(in);
} catch (Exception e) {
log.warn("Не удалось прочитать records '{}': {}", recordsPath, e.getMessage());
return;
}
if (!recordsArray.isArray()) {
log.warn("Records '{}' не массив, пропускаем", recordsPath);
return;
}
int created = 0;
int skipped = 0;
OffsetDateTime now = OffsetDateTime.now();
for (JsonNode rec : recordsArray) {
String businessKey = rec.path("businessKey").asText(null);
JsonNode data = rec.path("data");
if (businessKey == null || businessKey.isBlank() || data.isMissingNode()) {
log.warn("Пропуск некорректной записи в '{}': missing businessKey/data", recordsPath);
continue;
}
List<DictionaryRecord> existing = recordRepository
.findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(definition.getId(), businessKey);
if (!existing.isEmpty()) {
skipped++;
continue;
}
DictionaryRecord record = new DictionaryRecord(
UUID.randomUUID(), definition.getId(), businessKey, data, definition.getScope(), now);
DictionaryRecord savedRecord = recordRepository.save(record);
var recEvent = new DictionaryRecordCreated(
savedRecord.getId(), savedRecord.getBusinessKey(), savedRecord.getData(), null,
savedRecord.getValidFrom(), savedRecord.getValidTo(),
savedRecord.getCreatedAt(), savedRecord.getCreatedBy());
outboxRecorder.record(
"RecordCreated", "DictionaryRecord", savedRecord.getId().toString(),
definition.getName(), savedRecord.getDataScope(), manifest.bundle(),
"record:" + definition.getName() + ":" + savedRecord.getBusinessKey(),
EventEnvelope.of(definition.getName(), toEventScope(savedRecord.getDataScope()),
manifest.bundle(), recEvent));
created++;
}
log.info("Records для '{}': создано {}, пропущено (уже есть) {}",
entry.name(), created, skipped);
} }
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(DataScope d) { private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(DataScope d) {
@@ -0,0 +1,105 @@
[
{
"businessKey": "MOSCOW_OTRADNOE",
"data": {
"code": "MOSCOW_OTRADNOE",
"name": {
"ru-RU": "Отрадное",
"en-US": "Otradnoye"
},
"location": {
"ru-RU": "Московская область, Россия",
"en-US": "Moscow Region, Russia"
},
"country": "RU",
"lat": 56.099,
"lon": 37.566,
"elevation_m": 200,
"elevation_min_deg": 5,
"antennas": [
{
"id": "A1",
"diameter_m": 12,
"bands": ["X_BAND", "S_BAND"],
"polarizations": ["LHCP", "RHCP"],
"tx_capable": true,
"rx_capable": true,
"g_t_db_per_k": 32
}
],
"operator": {
"ru-RU": "Роскосмос",
"en-US": "Roscosmos"
},
"status": "OPERATIONAL"
}
},
{
"businessKey": "SVALBARD_SVALSAT",
"data": {
"code": "SVALBARD_SVALSAT",
"name": {
"ru-RU": "SvalSat (Шпицберген)",
"en-US": "SvalSat (Svalbard)"
},
"location": {
"ru-RU": "Шпицберген, Норвегия",
"en-US": "Svalbard, Norway"
},
"country": "NO",
"lat": 78.23,
"lon": 15.39,
"elevation_m": 458,
"elevation_min_deg": 3,
"antennas": [
{
"id": "S11",
"diameter_m": 13,
"bands": ["X_BAND", "S_BAND"],
"polarizations": ["LHCP", "RHCP"],
"tx_capable": true,
"rx_capable": true,
"g_t_db_per_k": 33
}
],
"operator": {
"ru-RU": "KSAT",
"en-US": "KSAT"
},
"status": "OPERATIONAL"
}
},
{
"businessKey": "BAIKONUR_TM",
"data": {
"code": "BAIKONUR_TM",
"name": {
"ru-RU": "Байконур (телеметрия)",
"en-US": "Baikonur (telemetry)"
},
"location": {
"ru-RU": "Байконур, Казахстан",
"en-US": "Baikonur, Kazakhstan"
},
"country": "KZ",
"lat": 45.965,
"lon": 63.305,
"elevation_m": 90,
"elevation_min_deg": 5,
"antennas": [
{
"id": "BK-1",
"diameter_m": 9,
"bands": ["S_BAND", "UHF"],
"rx_capable": true,
"tx_capable": true
}
],
"operator": {
"ru-RU": "Роскосмос",
"en-US": "Roscosmos"
},
"status": "OPERATIONAL"
}
}
]
@@ -0,0 +1,89 @@
[
{
"businessKey": "SAR_X_HIRES",
"data": {
"code": "SAR_X_HIRES",
"name": {
"ru-RU": "Радиолокатор синтезированной апертуры, X-диапазон, высокое разрешение",
"en-US": "Synthetic Aperture Radar, X-band, high resolution"
},
"description": {
"ru-RU": "SAR X-band КА с разрешением до 1 м",
"en-US": "SAR X-band satellite with up to 1 m resolution"
},
"mission_category": "RADAR_SAR",
"mass_class": "MEDIUM",
"typical_orbit_classes": ["LEO", "SSO"],
"typical_resolution_m": 1,
"typical_revisit_hours": 24
}
},
{
"businessKey": "OPT_HIRES_VIS",
"data": {
"code": "OPT_HIRES_VIS",
"name": {
"ru-RU": "Оптический КА высокого разрешения, видимый диапазон",
"en-US": "Optical high-resolution satellite, visible band"
},
"description": {
"ru-RU": "Оптика 0.5-1 м VIS, узкая полоса",
"en-US": "Optical 0.5-1 m VIS, narrow swath"
},
"mission_category": "OPTICAL_HIRES",
"mass_class": "MEDIUM",
"typical_orbit_classes": ["SSO"],
"typical_resolution_m": 0.5,
"typical_revisit_hours": 48
}
},
{
"businessKey": "OPT_MIDRES_MS",
"data": {
"code": "OPT_MIDRES_MS",
"name": {
"ru-RU": "Оптический мультиспектральный, среднее разрешение",
"en-US": "Optical multispectral, medium resolution"
},
"description": {
"ru-RU": "Multispectral 5-30 м, широкая полоса (типа Landsat/Sentinel-2)",
"en-US": "Multispectral 5-30 m, wide swath (Landsat/Sentinel-2 class)"
},
"mission_category": "OPTICAL_MIDRES",
"mass_class": "LARGE",
"typical_orbit_classes": ["SSO"],
"typical_resolution_m": 10,
"typical_revisit_hours": 120
}
},
{
"businessKey": "HYPER",
"data": {
"code": "HYPER",
"name": {
"ru-RU": "Гиперспектральный сканер",
"en-US": "Hyperspectral imager"
},
"mission_category": "HYPERSPECTRAL",
"mass_class": "MEDIUM",
"typical_orbit_classes": ["SSO"],
"typical_resolution_m": 30,
"typical_revisit_hours": 168
}
},
{
"businessKey": "METEO_GEO",
"data": {
"code": "METEO_GEO",
"name": {
"ru-RU": "Метеоспутник на геостационарной орбите",
"en-US": "Geostationary meteorological satellite"
},
"mission_category": "METEO",
"mass_class": "LARGE",
"typical_orbit_classes": ["GEO"],
"typical_resolution_m": 1000,
"typical_revisit_hours": 0.25
}
}
]
@@ -0,0 +1,122 @@
[
{
"businessKey": "1999-068A",
"data": {
"designator": "1999-068A",
"norad_id": 25994,
"name": {
"ru-RU": "Terra (EOS AM-1)",
"en-US": "Terra (EOS AM-1)"
},
"operator": {
"ru-RU": "НАСА",
"en-US": "NASA"
},
"country": "US",
"satellite_type_code": "OPT_MIDRES_MS",
"mass_kg": 4864,
"launch_date": "1999-12-18",
"status": "ACTIVE",
"orbit": {
"type": "SSO",
"semimajor_axis_km": 7083,
"eccentricity": 0.0001,
"inclination_deg": 98.2
},
"spectrum_bands": ["VIS", "NIR", "SWIR", "TIR"],
"mission": {
"ru-RU": "Глобальный мониторинг суши и атмосферы",
"en-US": "Global land and atmosphere monitoring"
}
}
},
{
"businessKey": "2014-016A",
"data": {
"designator": "2014-016A",
"norad_id": 39634,
"name": {
"ru-RU": "Sentinel-1A",
"en-US": "Sentinel-1A"
},
"operator": {
"ru-RU": "ESA / Copernicus",
"en-US": "ESA / Copernicus"
},
"country": "FR",
"satellite_type_code": "SAR_X_HIRES",
"mass_kg": 2300,
"launch_date": "2014-04-03",
"status": "ACTIVE",
"orbit": {
"type": "SSO",
"semimajor_axis_km": 7064,
"eccentricity": 0.0001,
"inclination_deg": 98.18
},
"spectrum_bands": ["C_BAND"],
"mission": {
"ru-RU": "C-SAR радиолокация для Copernicus",
"en-US": "C-SAR radar for Copernicus"
}
}
},
{
"businessKey": "2015-028A",
"data": {
"designator": "2015-028A",
"norad_id": 40697,
"name": {
"ru-RU": "Sentinel-2A",
"en-US": "Sentinel-2A"
},
"operator": {
"ru-RU": "ESA / Copernicus",
"en-US": "ESA / Copernicus"
},
"country": "FR",
"satellite_type_code": "OPT_MIDRES_MS",
"mass_kg": 1140,
"launch_date": "2015-06-23",
"status": "ACTIVE",
"orbit": {
"type": "SSO",
"semimajor_axis_km": 7167,
"inclination_deg": 98.62
},
"spectrum_bands": ["VIS", "NIR", "SWIR"],
"mission": {
"ru-RU": "Multispectral imaging для Copernicus",
"en-US": "Multispectral imaging for Copernicus"
}
}
},
{
"businessKey": "2024-149E",
"data": {
"designator": "2024-149E",
"name": {
"ru-RU": "Кондор-ФКА №2",
"en-US": "Kondor-FKA No.2"
},
"operator": {
"ru-RU": "Роскосмос",
"en-US": "Roscosmos"
},
"country": "RU",
"satellite_type_code": "SAR_X_HIRES",
"mass_kg": 1050,
"launch_date": "2024-08-13",
"status": "ACTIVE",
"orbit": {
"type": "SSO",
"inclination_deg": 97.5
},
"spectrum_bands": ["S_BAND"],
"mission": {
"ru-RU": "Радиолокационное наблюдение Земли",
"en-US": "Radar Earth observation"
}
}
}
]