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>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter</artifactId>
|
<artifactId>spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||||
<artifactId>ordinis-domain</artifactId>
|
<artifactId>ordinis-domain</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
</project>
|
</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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-2
@@ -63,7 +63,8 @@ public class RecordValidator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Рекурсивный обход schema. Для каждого {@code properties[name]} с
|
* Рекурсивный обход schema. Для каждого {@code properties[name]} с
|
||||||
* {@code x-localized: true} — проверяет значение в data.
|
* {@code x-localized: true} — проверяет значение в data, учитывая required
|
||||||
|
* массив родительской схемы (optional поля не требуют значения).
|
||||||
*/
|
*/
|
||||||
private void walkLocalizedFields(
|
private void walkLocalizedFields(
|
||||||
JsonNode schema,
|
JsonNode schema,
|
||||||
@@ -78,6 +79,12 @@ public class RecordValidator {
|
|||||||
JsonNode properties = schema.get("properties");
|
JsonNode properties = schema.get("properties");
|
||||||
if (properties == null || !properties.isObject()) return;
|
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();
|
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
|
||||||
while (it.hasNext()) {
|
while (it.hasNext()) {
|
||||||
Map.Entry<String, JsonNode> entry = it.next();
|
Map.Entry<String, JsonNode> entry = it.next();
|
||||||
@@ -86,10 +93,11 @@ public class RecordValidator {
|
|||||||
String fieldPath = path.isEmpty() ? "/" + fieldName : path + "/" + fieldName;
|
String fieldPath = path.isEmpty() ? "/" + fieldName : path + "/" + fieldName;
|
||||||
|
|
||||||
JsonNode dataField = data == null ? null : data.get(fieldName);
|
JsonNode dataField = data == null ? null : data.get(fieldName);
|
||||||
|
boolean fieldIsRequired = requiredFields.contains(fieldName);
|
||||||
|
|
||||||
JsonNode xLocalized = fieldSchema.get("x-localized");
|
JsonNode xLocalized = fieldSchema.get("x-localized");
|
||||||
if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) {
|
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")) {
|
} else if (fieldSchema.has("properties")) {
|
||||||
walkLocalizedFields(fieldSchema, dataField, fieldPath, supported, defaultLocale, errors);
|
walkLocalizedFields(fieldSchema, dataField, fieldPath, supported, defaultLocale, errors);
|
||||||
}
|
}
|
||||||
@@ -99,13 +107,16 @@ public class RecordValidator {
|
|||||||
private void validateLocalizedValue(
|
private void validateLocalizedValue(
|
||||||
String path,
|
String path,
|
||||||
JsonNode value,
|
JsonNode value,
|
||||||
|
boolean isRequired,
|
||||||
Set<String> supported,
|
Set<String> supported,
|
||||||
String defaultLocale,
|
String defaultLocale,
|
||||||
List<ValidationError> errors) {
|
List<ValidationError> errors) {
|
||||||
|
|
||||||
if (value == null || value.isNull()) {
|
if (value == null || value.isNull()) {
|
||||||
|
if (isRequired) {
|
||||||
errors.add(new ValidationError(path, "x-localized.missing",
|
errors.add(new ValidationError(path, "x-localized.missing",
|
||||||
"Localized field is required (must be object with locale keys)"));
|
"Localized field is required (must be object with locale keys)"));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!value.isObject()) {
|
if (!value.isObject()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user