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:
@@ -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>
|
||||
|
||||
+28
@@ -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) {}
|
||||
}
|
||||
+159
@@ -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());
|
||||
}
|
||||
}
|
||||
+14
@@ -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/";
|
||||
}
|
||||
}
|
||||
+23
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -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<String> requiredFields = new HashSet<>();
|
||||
JsonNode required = schema.get("required");
|
||||
if (required != null && required.isArray()) {
|
||||
required.forEach(n -> { if (n.isTextual()) requiredFields.add(n.asText()); });
|
||||
}
|
||||
|
||||
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<String, JsonNode> 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<String> supported,
|
||||
String defaultLocale,
|
||||
List<ValidationError> 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()) {
|
||||
|
||||
Reference in New Issue
Block a user