feat(cuod-bundle): JSON Schema драфты для spacecraft/satellite_type/ground_station + auto-import

3 schema драфта в classpath:/bundles/cuod/:

- spacecraft.schema.json: КА с COSPAR designator, NORAD ID, multi-locale
  name/operator/mission, orbit params (type/semimajor/eccentricity/inclination/
  raan/aop/epoch), spectrum_bands (12 bands), launch/decay dates, status enum
- satellite_type.schema.json: классификация КА — code, multi-locale name/desc,
  mission_category enum (10 categories), mass_class, typical_orbit_classes,
  typical_resolution_m, typical_revisit_hours
- ground_station.schema.json: code, multi-locale name/location/operator,
  country (ISO 3166), lat/lon/elevation, antennas array (id, diameter,
  bands, polarizations, tx/rx_capable, G/T, EIRP), status

manifest.json: bundle 'cuod' v1.0.0, 3 dictionaries с supported_locales
[ru-RU, en-US] default ru-RU, scope PUBLIC

ordinis-cuod-bundle модуль:
- BundleManifest record + DictionaryEntry record (Jackson deserializer)
- CuodBundleProperties @ConfigurationProperties (auto-import flag,
  update-existing-schema flag, resource-root path)
- CuodBundleImporter @Service (proxified, @Transactional работает) — читает
  manifest + schemas, для каждого: findByName → если нет, создать definition
  + outbox event DefinitionCreated; если есть, проверить schema_version, при
  отличии и update_existing_schema=true — обновить + DefinitionUpdated outbox
- CuodBundleStartupRunner @Configuration с ApplicationRunner — триггерит
  importBundle при старте Spring Boot. Отделено от Importer чтобы Importer
  оставался proxified (@Transactional на importBundle vs lambda не работает)

ordinis-validation FIX:
- RecordValidator теперь учитывает required array родительской схемы.
  Отсутствие x-localized optional поля (например mission, operator) больше
  не считается ошибкой. Required x-localized поля по-прежнему обязательны.

E2E проверено:
- ORDINIS_BUNDLE_CUOD_AUTO_IMPORT=true → 3 справочника создались с правильными
  scope, locales, schema_version
- POST spacecraft с реальными Kanopus-V данными (orbit + spectrum_bands) → 201
- POST satellite_type с code='lower-case-bad' → 422 pattern error
- Idempotent: повторный старт пропускает existing без ошибок
This commit is contained in:
Zimin A.N.
2026-05-03 14:29:43 +03:00
parent 51fcc95818
commit ae69a7642b
10 changed files with 558 additions and 4 deletions
+12
View File
@@ -19,9 +19,21 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-domain</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-outbox</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-events-api</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,28 @@
package cloud.nstart.terravault.ordinis.cuod;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record BundleManifest(
String bundle,
String displayName,
String description,
String version,
List<String> supportedLocales,
String defaultLocale,
List<DictionaryEntry> dictionaries) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record DictionaryEntry(
String name,
String displayName,
String description,
DataScope scope,
String schemaResource,
String schemaVersion,
List<String> supportedLocales,
String defaultLocale) {}
}
@@ -0,0 +1,159 @@
package cloud.nstart.terravault.ordinis.cuod;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* При старте сервиса проверяет/создаёт справочники из ЦУОД bundle.
*
* <p>Идемпотентен: если справочник уже существует — сравнивает schema_version,
* при отличии и {@code updateExistingSchema=true} обновляет (с DefinitionUpdated
* outbox event). В противном случае не трогает.
*/
@Service
@Configuration
@EnableConfigurationProperties(CuodBundleProperties.class)
@ConditionalOnProperty(name = "ordinis.bundle.cuod.auto-import", havingValue = "true", matchIfMissing = false)
public class CuodBundleImporter {
private static final Logger log = LoggerFactory.getLogger(CuodBundleImporter.class);
private final CuodBundleProperties props;
private final DictionaryDefinitionRepository repository;
private final OutboxRecorder outboxRecorder;
private final ObjectMapper objectMapper;
private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
public CuodBundleImporter(
CuodBundleProperties props,
DictionaryDefinitionRepository repository,
OutboxRecorder outboxRecorder,
ObjectMapper objectMapper) {
this.props = props;
this.repository = repository;
this.outboxRecorder = outboxRecorder;
this.objectMapper = objectMapper;
}
@Transactional
public void importBundle() throws Exception {
Resource manifestResource = resolver.getResource(props.resourceRoot() + "manifest.json");
if (!manifestResource.exists()) {
log.warn("ЦУОД bundle manifest не найден по пути {}, пропускаем auto-import", props.resourceRoot());
return;
}
BundleManifest manifest;
try (var in = manifestResource.getInputStream()) {
manifest = objectMapper.readValue(in, BundleManifest.class);
}
log.info("Импорт ЦУОД bundle '{}' v{}: {} справочников",
manifest.bundle(), manifest.version(), manifest.dictionaries().size());
for (BundleManifest.DictionaryEntry entry : manifest.dictionaries()) {
try {
importEntry(manifest, entry);
} catch (Exception e) {
log.error("Ошибка импорта справочника '{}': {}", entry.name(), e.getMessage(), e);
}
}
log.info("Импорт ЦУОД bundle '{}' завершён", manifest.bundle());
}
private void importEntry(BundleManifest manifest, BundleManifest.DictionaryEntry entry) throws Exception {
Resource schemaResource = resolver.getResource(props.resourceRoot() + entry.schemaResource());
JsonNode schema;
try (var in = schemaResource.getInputStream()) {
schema = objectMapper.readTree(in);
}
List<String> entryLocales = entry.supportedLocales() == null || entry.supportedLocales().isEmpty()
? manifest.supportedLocales()
: entry.supportedLocales();
String entryDefault = entry.defaultLocale() == null ? manifest.defaultLocale() : entry.defaultLocale();
Optional<DictionaryDefinition> existing = repository.findByName(entry.name());
if (existing.isPresent()) {
DictionaryDefinition def = existing.get();
if (!props.updateExistingSchema()) {
log.debug("Справочник '{}' уже существует, update_existing_schema=false — пропускаем", entry.name());
return;
}
if (entry.schemaVersion().equals(def.getSchemaVersion())) {
log.debug("Справочник '{}' v{} актуален", entry.name(), def.getSchemaVersion());
return;
}
JsonNode prevSchema = def.getSchemaJson();
def.setSchemaJson(schema);
def.setSchemaVersion(entry.schemaVersion());
def.setDescription(entry.description());
def.setDisplayName(entry.displayName());
def.setSupportedLocales(entryLocales.toArray(String[]::new));
def.setDefaultLocale(entryDefault);
DictionaryDefinition saved = repository.save(def);
var event = new DictionaryDefinitionUpdated(
saved.getId(), saved.getName(), saved.getDisplayName(), saved.getDescription(),
toEventScope(saved.getScope()), saved.getSchemaJson(), prevSchema,
saved.getSchemaVersion(), saved.getBundle(),
List.of(saved.getSupportedLocales()), saved.getDefaultLocale(),
saved.getUpdatedAt(), saved.getUpdatedBy());
outboxRecorder.record(
"DefinitionUpdated", "DictionaryDefinition", saved.getId().toString(),
saved.getName(), saved.getScope(), manifest.bundle(),
"definition:" + saved.getName(),
EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event));
log.info("Справочник '{}' обновлён до v{}", saved.getName(), saved.getSchemaVersion());
return;
}
DictionaryDefinition fresh = new DictionaryDefinition(
UUID.randomUUID(), entry.name(), entry.scope(), schema);
fresh.setDisplayName(entry.displayName());
fresh.setDescription(entry.description());
fresh.setSchemaVersion(entry.schemaVersion());
fresh.setBundle(manifest.bundle());
fresh.setSupportedLocales(entryLocales.toArray(String[]::new));
fresh.setDefaultLocale(entryDefault);
DictionaryDefinition saved = repository.save(fresh);
var event = new DictionaryDefinitionCreated(
saved.getId(), saved.getName(), saved.getDisplayName(), saved.getDescription(),
toEventScope(saved.getScope()), saved.getSchemaJson(),
saved.getSchemaVersion(), saved.getBundle(),
List.of(saved.getSupportedLocales()), saved.getDefaultLocale(),
saved.getCreatedAt(), saved.getCreatedBy());
outboxRecorder.record(
"DefinitionCreated", "DictionaryDefinition", saved.getId().toString(),
saved.getName(), saved.getScope(), manifest.bundle(),
"definition:" + saved.getName(),
EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event));
log.info("Справочник '{}' создан (scope={}, locales={}, default={})",
saved.getName(), saved.getScope(), entryLocales, entryDefault);
}
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(DataScope d) {
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
}
}
@@ -0,0 +1,14 @@
package cloud.nstart.terravault.ordinis.cuod;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "ordinis.bundle.cuod")
public record CuodBundleProperties(
boolean autoImport,
boolean updateExistingSchema,
String resourceRoot) {
public CuodBundleProperties {
if (resourceRoot == null) resourceRoot = "classpath:/bundles/cuod/";
}
}
@@ -0,0 +1,23 @@
package cloud.nstart.terravault.ordinis.cuod;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Триггерит {@link CuodBundleImporter#importBundle()} при старте Spring Boot.
* Отделено от Importer в свой Configuration чтобы Importer оставался
* proxified Spring service с работающим @Transactional.
*/
@Configuration
@ConditionalOnProperty(name = "ordinis.bundle.cuod.auto-import", havingValue = "true", matchIfMissing = false)
public class CuodBundleStartupRunner {
@Bean
@ConditionalOnBean(CuodBundleImporter.class)
public ApplicationRunner cuodBundleImportRunner(CuodBundleImporter importer) {
return args -> importer.importBundle();
}
}
@@ -0,0 +1,105 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ordinis.nstart.cloud/schemas/cuod/ground_station/1.0.0",
"title": "Ground Station",
"description": "Наземная станция приёма и/или управления КА. Геометрия (точка) пишется в отдельную колонку geometry, не дублируется в data.lat/lon.",
"type": "object",
"required": ["code", "name"],
"additionalProperties": false,
"properties": {
"code": {
"type": "string",
"description": "Уникальный код станции",
"pattern": "^[A-Z][A-Z0-9_-]{1,31}$"
},
"name": {
"type": "object",
"x-localized": true,
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
},
"required": ["ru-RU"]
},
"location": {
"type": "object",
"x-localized": true,
"description": "Текстовое описание местоположения (город, регион, страна)",
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
}
},
"country": {
"type": "string",
"description": "ISO 3166-1 alpha-2",
"pattern": "^[A-Z]{2}$"
},
"lat": {
"type": "number",
"description": "Широта (для удобства поиска без geometry; geometry — source of truth)",
"minimum": -90,
"maximum": 90
},
"lon": {
"type": "number",
"minimum": -180,
"maximum": 180
},
"elevation_m": {
"type": "number",
"description": "Высота над уровнем моря, м"
},
"elevation_min_deg": {
"type": "number",
"description": "Минимальный угол места, градусы (горизонт станции)",
"minimum": 0,
"maximum": 90
},
"antennas": {
"type": "array",
"description": "Антенные системы станции",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "diameter_m", "bands"],
"properties": {
"id": { "type": "string" },
"diameter_m": { "type": "number", "minimum": 0 },
"bands": {
"type": "array",
"items": {
"type": "string",
"enum": ["L_BAND", "S_BAND", "C_BAND", "X_BAND", "KU_BAND", "KA_BAND", "UHF", "VHF"]
},
"minItems": 1,
"uniqueItems": true
},
"polarizations": {
"type": "array",
"items": {
"type": "string",
"enum": ["LHCP", "RHCP", "H", "V"]
}
},
"tx_capable": { "type": "boolean", "default": false },
"rx_capable": { "type": "boolean", "default": true },
"g_t_db_per_k": {
"type": "number",
"description": "G/T (gain-to-noise-temperature ratio), дБ/К"
},
"eirp_dbw": { "type": "number" }
}
}
},
"operator": {
"type": "object",
"x-localized": true,
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
}
},
"status": {
"type": "string",
"enum": ["PLANNED", "OPERATIONAL", "MAINTENANCE", "DECOMMISSIONED"]
}
}
}
@@ -0,0 +1,34 @@
{
"bundle": "cuod",
"displayName": "ЦУОД ДЗЗ",
"description": "Bundle справочников для Центра Управления Обработкой Данных ДЗЗ. Anchor v1.",
"version": "1.0.0",
"supportedLocales": ["ru-RU", "en-US"],
"defaultLocale": "ru-RU",
"dictionaries": [
{
"name": "spacecraft",
"displayName": "Космические аппараты",
"description": "Каталог КА (космических аппаратов) ДЗЗ-миссий",
"scope": "PUBLIC",
"schemaResource": "spacecraft.schema.json",
"schemaVersion": "1.0.0"
},
{
"name": "satellite_type",
"displayName": "Типы КА",
"description": "Классификация типов космических аппаратов по миссиям и характеристикам",
"scope": "PUBLIC",
"schemaResource": "satellite_type.schema.json",
"schemaVersion": "1.0.0"
},
{
"name": "ground_station",
"displayName": "Наземные станции",
"description": "Каталог наземных пунктов приёма и управления",
"scope": "PUBLIC",
"schemaResource": "ground_station.schema.json",
"schemaVersion": "1.0.0"
}
]
}
@@ -0,0 +1,69 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ordinis.nstart.cloud/schemas/cuod/satellite_type/1.0.0",
"title": "Satellite Type",
"description": "Тип КА — классификация по миссии и массовому классу. Используется как FK в spacecraft.satellite_type_code.",
"type": "object",
"required": ["code", "name", "mission_category"],
"additionalProperties": false,
"properties": {
"code": {
"type": "string",
"description": "Уникальный код типа",
"pattern": "^[A-Z][A-Z0-9_]{1,31}$"
},
"name": {
"type": "object",
"x-localized": true,
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
},
"required": ["ru-RU"]
},
"description": {
"type": "object",
"x-localized": true,
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
}
},
"mission_category": {
"type": "string",
"enum": [
"OPTICAL_HIRES",
"OPTICAL_MIDRES",
"OPTICAL_LOWRES",
"RADAR_SAR",
"HYPERSPECTRAL",
"THERMAL",
"METEO",
"ATMOSPHERIC",
"OCEAN",
"GEODESY"
]
},
"mass_class": {
"type": "string",
"description": "Массовый класс (по конвенциям ESA/NASA)",
"enum": ["FEMTO", "PICO", "NANO", "MICRO", "MINI", "SMALL", "MEDIUM", "LARGE"]
},
"typical_orbit_classes": {
"type": "array",
"items": {
"type": "string",
"enum": ["LEO", "MEO", "GEO", "HEO", "SSO"]
},
"uniqueItems": true
},
"typical_resolution_m": {
"type": "number",
"description": "Типичное пространственное разрешение, м",
"minimum": 0
},
"typical_revisit_hours": {
"type": "number",
"description": "Типичный период повторного покрытия, часов",
"minimum": 0
}
}
}
@@ -0,0 +1,99 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ordinis.nstart.cloud/schemas/cuod/spacecraft/1.0.0",
"title": "Spacecraft (КА)",
"description": "Космический аппарат ДЗЗ. Идентифицируется designator'ом (международное обозначение COSPAR/NORAD ID).",
"type": "object",
"required": ["designator", "name", "satellite_type_code", "status"],
"additionalProperties": false,
"properties": {
"designator": {
"type": "string",
"description": "Международное обозначение (COSPAR designator), например 1998-067A",
"pattern": "^[0-9]{4}-[0-9]{3}[A-Z]+$"
},
"norad_id": {
"type": "integer",
"description": "NORAD catalog number",
"minimum": 1
},
"name": {
"type": "object",
"x-localized": true,
"description": "Наименование аппарата",
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
},
"required": ["ru-RU"]
},
"operator": {
"type": "object",
"x-localized": true,
"description": "Оператор/владелец КА",
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
}
},
"country": {
"type": "string",
"description": "ISO 3166-1 alpha-2 код страны-оператора",
"pattern": "^[A-Z]{2}$"
},
"satellite_type_code": {
"type": "string",
"description": "FK на satellite_type.code"
},
"mass_kg": {
"type": "number",
"description": "Масса КА на старте, кг",
"minimum": 0
},
"launch_date": {
"type": "string",
"format": "date"
},
"decay_date": {
"type": "string",
"format": "date",
"description": "Дата схода с орбиты (если применимо)"
},
"status": {
"type": "string",
"enum": ["PLANNED", "ACTIVE", "INACTIVE", "DECAYED", "LOST"]
},
"orbit": {
"type": "object",
"description": "Параметры орбиты на эпоху",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["LEO", "MEO", "GEO", "HEO", "SSO", "OTHER"]
},
"semimajor_axis_km": { "type": "number", "minimum": 0 },
"eccentricity": { "type": "number", "minimum": 0, "maximum": 1 },
"inclination_deg": { "type": "number", "minimum": 0, "maximum": 180 },
"raan_deg": { "type": "number", "minimum": 0, "maximum": 360 },
"argument_of_perigee_deg": { "type": "number", "minimum": 0, "maximum": 360 },
"epoch": { "type": "string", "format": "date-time" }
}
},
"spectrum_bands": {
"type": "array",
"description": "Спектральные диапазоны полезной нагрузки",
"items": {
"type": "string",
"enum": ["VIS", "NIR", "SWIR", "MWIR", "LWIR", "TIR", "L_BAND", "S_BAND", "C_BAND", "X_BAND", "KU_BAND", "KA_BAND"]
},
"uniqueItems": true
},
"mission": {
"type": "object",
"x-localized": true,
"description": "Краткое описание миссии",
"patternProperties": {
"^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
}
}
}
}