From ae69a7642b430be054c86cab3bd88c4076eb6f09 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sun, 3 May 2026 14:29:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(cuod-bundle):=20JSON=20Schema=20=D0=B4?= =?UTF-8?q?=D1=80=D0=B0=D1=84=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20spacecraf?= =?UTF-8?q?t/satellite=5Ftype/ground=5Fstation=20+=20auto-import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 без ошибок --- ordinis-cuod-bundle/pom.xml | 12 ++ .../ordinis/cuod/BundleManifest.java | 28 +++ .../ordinis/cuod/CuodBundleImporter.java | 159 ++++++++++++++++++ .../ordinis/cuod/CuodBundleProperties.java | 14 ++ .../ordinis/cuod/CuodBundleStartupRunner.java | 23 +++ .../bundles/cuod/ground_station.schema.json | 105 ++++++++++++ .../main/resources/bundles/cuod/manifest.json | 34 ++++ .../bundles/cuod/satellite_type.schema.json | 69 ++++++++ .../bundles/cuod/spacecraft.schema.json | 99 +++++++++++ .../ordinis/validation/RecordValidator.java | 19 ++- 10 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/BundleManifest.java create mode 100644 ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java create mode 100644 ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleProperties.java create mode 100644 ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleStartupRunner.java create mode 100644 ordinis-cuod-bundle/src/main/resources/bundles/cuod/ground_station.schema.json create mode 100644 ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json create mode 100644 ordinis-cuod-bundle/src/main/resources/bundles/cuod/satellite_type.schema.json create mode 100644 ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json diff --git a/ordinis-cuod-bundle/pom.xml b/ordinis-cuod-bundle/pom.xml index 1577f96..c439765 100644 --- a/ordinis-cuod-bundle/pom.xml +++ b/ordinis-cuod-bundle/pom.xml @@ -19,9 +19,21 @@ org.springframework.boot spring-boot-starter + + com.fasterxml.jackson.core + jackson-databind + cloud.nstart.terravault.ordinis ordinis-domain + + cloud.nstart.terravault.ordinis + ordinis-outbox + + + cloud.nstart.terravault.ordinis + ordinis-events-api + diff --git a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/BundleManifest.java b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/BundleManifest.java new file mode 100644 index 0000000..46af1e7 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/BundleManifest.java @@ -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 supportedLocales, + String defaultLocale, + List dictionaries) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record DictionaryEntry( + String name, + String displayName, + String description, + DataScope scope, + String schemaResource, + String schemaVersion, + List supportedLocales, + String defaultLocale) {} +} 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 new file mode 100644 index 0000000..1908421 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java @@ -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. + * + *

Идемпотентен: если справочник уже существует — сравнивает 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 entryLocales = entry.supportedLocales() == null || entry.supportedLocales().isEmpty() + ? manifest.supportedLocales() + : entry.supportedLocales(); + String entryDefault = entry.defaultLocale() == null ? manifest.defaultLocale() : entry.defaultLocale(); + + Optional 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()); + } +} diff --git a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleProperties.java b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleProperties.java new file mode 100644 index 0000000..8a22e85 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleProperties.java @@ -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/"; + } +} diff --git a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleStartupRunner.java b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleStartupRunner.java new file mode 100644 index 0000000..4aed7d5 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleStartupRunner.java @@ -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(); + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/ground_station.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/ground_station.schema.json new file mode 100644 index 0000000..ca3bcc5 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/ground_station.schema.json @@ -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"] + } + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json new file mode 100644 index 0000000..e547f75 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json @@ -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" + } + ] +} diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/satellite_type.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/satellite_type.schema.json new file mode 100644 index 0000000..ae9369e --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/satellite_type.schema.json @@ -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 + } + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json new file mode 100644 index 0000000..e9fb4c9 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json @@ -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" } + } + } + } +} diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java index 6b19ac1..1b7c85a 100644 --- a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java +++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java @@ -63,7 +63,8 @@ public class RecordValidator { /** * Рекурсивный обход schema. Для каждого {@code properties[name]} с - * {@code x-localized: true} — проверяет значение в data. + * {@code x-localized: true} — проверяет значение в data, учитывая required + * массив родительской схемы (optional поля не требуют значения). */ private void walkLocalizedFields( JsonNode schema, @@ -78,6 +79,12 @@ public class RecordValidator { JsonNode properties = schema.get("properties"); if (properties == null || !properties.isObject()) return; + Set requiredFields = new HashSet<>(); + JsonNode required = schema.get("required"); + if (required != null && required.isArray()) { + required.forEach(n -> { if (n.isTextual()) requiredFields.add(n.asText()); }); + } + Iterator> it = properties.fields(); while (it.hasNext()) { Map.Entry entry = it.next(); @@ -86,10 +93,11 @@ public class RecordValidator { String fieldPath = path.isEmpty() ? "/" + fieldName : path + "/" + fieldName; JsonNode dataField = data == null ? null : data.get(fieldName); + boolean fieldIsRequired = requiredFields.contains(fieldName); JsonNode xLocalized = fieldSchema.get("x-localized"); if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) { - validateLocalizedValue(fieldPath, dataField, supported, defaultLocale, errors); + validateLocalizedValue(fieldPath, dataField, fieldIsRequired, supported, defaultLocale, errors); } else if (fieldSchema.has("properties")) { walkLocalizedFields(fieldSchema, dataField, fieldPath, supported, defaultLocale, errors); } @@ -99,13 +107,16 @@ public class RecordValidator { private void validateLocalizedValue( String path, JsonNode value, + boolean isRequired, Set supported, String defaultLocale, List errors) { if (value == null || value.isNull()) { - errors.add(new ValidationError(path, "x-localized.missing", - "Localized field is required (must be object with locale keys)")); + if (isRequired) { + errors.add(new ValidationError(path, "x-localized.missing", + "Localized field is required (must be object with locale keys)")); + } return; } if (!value.isObject()) {