From be77c30687145e2216b82693c450282fe115fe0a Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sun, 3 May 2026 13:04:36 +0300 Subject: [PATCH] feat(validation,rest-api,outbox): write-side end-to-end working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ordinis-validation: - ValidationError, ValidationResult, ValidationException - RecordValidator: networknt JSON Schema (Draft-7) + custom rule для x-localized полей. Walk schema, для каждого x-localized:true property проверяет: значение это object с locale keys (BCP 47 pattern xx-XX), все ключи в supported_locales, default_locale обязательно присутствует, значения non-empty strings. ordinis-outbox: - KafkaTopicResolver: ordinis..events. - OutboxRecorder: запись события в outbox в той же транзакции (MANDATORY propagation), с tracer для trace_id/span_id из MDC если OTel доступен - OutboxPoller: @Scheduled, batch_size + poll_interval из config, метрики ordinis_outbox_published_total / publish_errors_total / kafka_publish_duration_seconds - OutboxLagGauge: ordinis_outbox_pending_events (gauge для алертов) - ConditionalOnProperty ordinis.outbox.enabled — отключаем в read-api/projection-writer ordinis-rest-api: - DTOs: CreateDictionaryRequest, DictionaryResponse, CreateRecordRequest, RecordResponse (с WKT serialization geometry) - DictionaryDefinitionService: CRUD definitions, outbox events DefinitionCreated/Updated - DictionaryRecordService: bitemporal write-side, SERIALIZABLE isolation, bound check FAR_FUTURE valid_to. Update = close current + create new (никогда in-place). Outbox event RecordCreated/Updated/Deleted. - DictionaryDefinitionController: GET list/by-name, POST create, PUT update - DictionaryRecordController: GET active/history, POST create, PUT update, DELETE (close) - OrdinisException + ApiErrorResponse + GlobalExceptionHandler (422 validation, 404 not found, 409 conflict, 500 internal) ordinis-app: - @EnableJpaRepositories + @EntityScan для multi-package сканирования - application.yml profile-based: dev (PG localhost через port-forward, ddl-auto=update, OTel disabled), k8s (Cloud Config + Vault Kubernetes auth) - spring.kafka producer config с SASL SCRAM-SHA-512 ordinis-domain: - JpaAuditingConfig: добавлен DateTimeProvider возвращающий OffsetDateTime (Spring Data Auditing по дефолту его не поддерживает, без него падает IllegalArgumentException 'Cannot convert LocalDateTime to OffsetDateTime') - @ConditionalOnBean(DataSource) убран — race с lifecycle ломал auditing E2E test (port-forward к cluster PG+Kafka): - POST dictionary 'ground_station' → 201, JSONB schema сохранена, supported_locales = {ru-RU, en-US}, default_locale = ru-RU - POST record MOSCOW-1 multi-locale name + geometry POINT(37.618 55.751) → 201 - POST record с en-US без ru-RU → 422 с двумя ошибками: * networknt 'required property ru-RU not found' * custom 'x-localized.missing_default Default locale ru-RU is required' - outbox_events: 2 row (DefinitionCreated + RecordCreated), topic ordinis.cuod.events.public, ключи правильные - revinfo + dictionary_records_aud: 1 row (Envers tx-time history активен) --- .../ordinis/app/OrdinisApplication.java | 4 + .../src/main/resources/application.yml | 57 ++++- .../domain/config/JpaAuditingConfig.java | 26 ++- ordinis-outbox/pom.xml | 12 + .../ordinis/outbox/KafkaTopicResolver.java | 20 ++ .../ordinis/outbox/OutboxLagGauge.java | 33 +++ .../ordinis/outbox/OutboxPoller.java | 112 ++++++++++ .../ordinis/outbox/OutboxRecorder.java | 75 +++++++ ordinis-rest-api/pom.xml | 12 + .../restapi/dto/CreateDictionaryRequest.java | 22 ++ .../restapi/dto/CreateRecordRequest.java | 16 ++ .../restapi/dto/DictionaryResponse.java | 46 ++++ .../ordinis/restapi/dto/RecordResponse.java | 45 ++++ .../restapi/error/ApiErrorResponse.java | 27 +++ .../restapi/error/GlobalExceptionHandler.java | 61 +++++ .../restapi/error/OrdinisException.java | 34 +++ .../service/DictionaryDefinitionService.java | 139 ++++++++++++ .../service/DictionaryRecordService.java | 210 ++++++++++++++++++ .../web/DictionaryDefinitionController.java | 52 +++++ .../web/DictionaryRecordController.java | 79 +++++++ .../ordinis/validation/RecordValidator.java | 144 ++++++++++++ .../ordinis/validation/ValidationError.java | 8 + .../validation/ValidationException.java | 17 ++ .../ordinis/validation/ValidationResult.java | 14 ++ 24 files changed, 1246 insertions(+), 19 deletions(-) create mode 100644 ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/KafkaTopicResolver.java create mode 100644 ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxLagGauge.java create mode 100644 ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxPoller.java create mode 100644 ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxRecorder.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateRecordRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/RecordResponse.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/ApiErrorResponse.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java create mode 100644 ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java create mode 100644 ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationError.java create mode 100644 ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationException.java create mode 100644 ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationResult.java diff --git a/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java b/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java index 327a0c7..32902b3 100644 --- a/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java +++ b/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java @@ -2,9 +2,13 @@ package cloud.nstart.terravault.ordinis.app; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(scanBasePackages = "cloud.nstart.terravault.ordinis") +@EnableJpaRepositories(basePackages = "cloud.nstart.terravault.ordinis") +@EntityScan(basePackages = "cloud.nstart.terravault.ordinis") @EnableScheduling public class OrdinisApplication { diff --git a/ordinis-app/src/main/resources/application.yml b/ordinis-app/src/main/resources/application.yml index 478ee89..c095503 100644 --- a/ordinis-app/src/main/resources/application.yml +++ b/ordinis-app/src/main/resources/application.yml @@ -12,7 +12,7 @@ management: endpoints: web: exposure: - include: health,info,prometheus,refresh,metrics,env + include: health,info,prometheus,refresh,metrics,env,mappings base-path: /actuator endpoint: health: @@ -23,7 +23,7 @@ management: liveness: include: livenessState readiness: - include: readinessState + include: readinessState,db prometheus: metrics: export: @@ -33,7 +33,6 @@ management: application: ${spring.application.name} environment: ${ENVIRONMENT:dev} -# Defaults для OTel (можно отключить через otel.sdk.disabled=true в dev) otel: exporter: otlp: @@ -53,8 +52,16 @@ logging: root: INFO cloud.nstart.terravault.ordinis: DEBUG +ordinis: + outbox: + enabled: ${ORDINIS_OUTBOX_ENABLED:true} + poll-interval-ms: ${ORDINIS_OUTBOX_POLL_INTERVAL_MS:1000} + batch-size: ${ORDINIS_OUTBOX_BATCH_SIZE:100} + --- -# DEV profile — local run без Cloud Config / Vault / OTel. +# DEV profile: подключается к cluster PG+Kafka через kubectl port-forward. +# Перед запуском: kubectl port-forward -n ordinis-dev svc/ordinis-postgres-rw 5432:5432 & +# kubectl port-forward -n ordinis-dev svc/ordinis-kafka-kafka-bootstrap 9092:9092 & spring: config: activate: @@ -66,17 +73,45 @@ spring: enabled: false autoconfigure: exclude: - - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration - - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration - - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration + - org.springframework.cloud.vault.config.VaultAutoConfiguration + - org.springframework.cloud.vault.config.VaultObservationAutoConfiguration + - org.springframework.cloud.vault.config.VaultReactiveAutoConfiguration + datasource: + url: jdbc:postgresql://${ORDINIS_PG_HOST:localhost}:${ORDINIS_PG_PORT:5432}/${ORDINIS_PG_DB:ordinis} + username: ${ORDINIS_PG_USER:ordinis_app} + password: ${ORDINIS_PG_PASSWORD:} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + properties: + hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect + hibernate.format_sql: true + hibernate.jdbc.lob.non_contextual_creation: true + open-in-view: false + kafka: + bootstrap-servers: ${ORDINIS_KAFKA_BOOTSTRAP:localhost:9092} + properties: + security.protocol: ${ORDINIS_KAFKA_SECURITY:SASL_PLAINTEXT} + sasl.mechanism: ${ORDINIS_KAFKA_SASL_MECHANISM:SCRAM-SHA-512} + sasl.jaas.config: >- + org.apache.kafka.common.security.scram.ScramLoginModule required + username="${ORDINIS_KAFKA_USER:ordinis-app}" + password="${ORDINIS_KAFKA_PASSWORD:}"; + producer: + acks: all + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + properties: + enable.idempotence: true + compression.type: lz4 otel: sdk: disabled: true --- -# K8s profile — реальная инфра: Spring Cloud Config + Vault Kubernetes auth. +# K8s profile: реальная инфра в кластере. Cloud Config + Vault Kubernetes auth. spring: config: activate: @@ -98,3 +133,7 @@ spring: enabled: true backend: secret application-name: ordinis/cuod + jpa: + hibernate: + ddl-auto: validate + open-in-view: false diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java index 34e9e3b..2d1bbd7 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java @@ -1,27 +1,28 @@ package cloud.nstart.terravault.ordinis.domain.config; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; import org.springframework.data.domain.AuditorAware; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import javax.sql.DataSource; +import java.time.OffsetDateTime; import java.util.Optional; /** - * Включает {@code @CreatedBy}/{@code @LastModifiedBy}. Domain не знает откуда - * приходит user identity — модули выше (app, rest-api) подключают свой - * {@link AuditorAware} (JWT subject из @nstart/auth). Default тут возвращает - * {@code "system"} для batch/internal операций. + * Включает {@code @CreatedBy}/{@code @LastModifiedBy}/{@code @CreatedDate}/{@code @LastModifiedDate}. * - *

{@link ConditionalOnBean} на {@link DataSource} — без БД (dev smoke - * profile) auditing не активируется, JPA metamodel не строится. + *

Domain не знает откуда приходит user identity — модули выше (app, + * rest-api) подключают свой {@link AuditorAware} (JWT subject из @nstart/auth). + * Default тут возвращает {@code "system"} для batch/internal операций. + * + *

{@link DateTimeProvider} явно возвращает {@link OffsetDateTime} — Spring + * Data Auditing по дефолту не поддерживает {@code OffsetDateTime}, так что без + * этого bean'а получим {@code IllegalArgumentException} при сохранении. */ @Configuration -@ConditionalOnBean(DataSource.class) -@EnableJpaAuditing(auditorAwareRef = "auditorAware") +@EnableJpaAuditing(auditorAwareRef = "auditorAware", dateTimeProviderRef = "auditingDateTimeProvider") public class JpaAuditingConfig { @Bean @@ -29,4 +30,9 @@ public class JpaAuditingConfig { public AuditorAware auditorAware() { return () -> Optional.of("system"); } + + @Bean + public DateTimeProvider auditingDateTimeProvider() { + return () -> Optional.of(OffsetDateTime.now()); + } } diff --git a/ordinis-outbox/pom.xml b/ordinis-outbox/pom.xml index 751dc49..fcece78 100644 --- a/ordinis-outbox/pom.xml +++ b/ordinis-outbox/pom.xml @@ -23,6 +23,18 @@ org.springframework.kafka spring-kafka + + com.fasterxml.jackson.core + jackson-databind + + + io.micrometer + micrometer-core + + + io.micrometer + micrometer-tracing + cloud.nstart.terravault.ordinis ordinis-domain diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/KafkaTopicResolver.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/KafkaTopicResolver.java new file mode 100644 index 0000000..a2d2195 --- /dev/null +++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/KafkaTopicResolver.java @@ -0,0 +1,20 @@ +package cloud.nstart.terravault.ordinis.outbox; + +import cloud.nstart.terravault.ordinis.domain.DataScope; + +/** + * Маршрутизация события в правильный scope-топик. Pattern: + * {@code ordinis..events.}. + * + *

Bundle сейчас всегда "cuod" (anchor customer); для второго клиента — + * другой bundle, своя тройка топиков. ACL/provisioning через Strimzi (см. + * ordinis-infra/k8s/kafka/topology.yaml). + */ +public final class KafkaTopicResolver { + + private KafkaTopicResolver() {} + + public static String topic(String bundle, DataScope scope) { + return "ordinis." + bundle + ".events." + scope.topicSuffix(); + } +} diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxLagGauge.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxLagGauge.java new file mode 100644 index 0000000..e523961 --- /dev/null +++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxLagGauge.java @@ -0,0 +1,33 @@ +package cloud.nstart.terravault.ordinis.outbox; + +import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Gauge {@code ordinis_outbox_pending_events} — сколько unpublished. Алертится + * Prometheus'ом если растёт (poller не успевает или Kafka недоступна). + */ +@Component +@ConditionalOnProperty(name = "ordinis.outbox.enabled", havingValue = "true", matchIfMissing = false) +public class OutboxLagGauge { + + private final OutboxEventRepository repository; + private final MeterRegistry meterRegistry; + + public OutboxLagGauge(OutboxEventRepository repository, MeterRegistry meterRegistry) { + this.repository = repository; + this.meterRegistry = meterRegistry; + } + + @PostConstruct + void register() { + Gauge.builder("ordinis_outbox_pending_events", repository, + r -> (double) r.countByPublishedAtIsNull()) + .description("Количество outbox events ожидающих публикацию в Kafka") + .register(meterRegistry); + } +} diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxPoller.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxPoller.java new file mode 100644 index 0000000..1f1542b --- /dev/null +++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxPoller.java @@ -0,0 +1,112 @@ +package cloud.nstart.terravault.ordinis.outbox; + +import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent; +import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * @Scheduled poller для outbox. Sweep каждые {@code ordinis.outbox.poll-interval-ms}, + * batch size {@code ordinis.outbox.batch-size}. Активируется только если + * {@code ordinis.outbox.enabled=true} (отключаем в read-api / projection-writer). + */ +@Component +@ConditionalOnProperty(name = "ordinis.outbox.enabled", havingValue = "true", matchIfMissing = false) +public class OutboxPoller { + + private static final Logger log = LoggerFactory.getLogger(OutboxPoller.class); + + private final OutboxEventRepository repository; + private final KafkaTemplate kafkaTemplate; + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; + private final Counter publishedCounter; + private final Counter failedCounter; + private final Timer publishTimer; + + @Value("${ordinis.outbox.batch-size:100}") + private int batchSize; + + @Value("${ordinis.outbox.send-timeout-ms:10000}") + private long sendTimeoutMs; + + public OutboxPoller( + OutboxEventRepository repository, + KafkaTemplate kafkaTemplate, + com.fasterxml.jackson.databind.ObjectMapper objectMapper, + MeterRegistry meterRegistry) { + this.repository = repository; + this.kafkaTemplate = kafkaTemplate; + this.objectMapper = objectMapper; + this.publishedCounter = Counter.builder("ordinis_outbox_published_total") + .description("Количество успешно опубликованных outbox events") + .register(meterRegistry); + this.failedCounter = Counter.builder("ordinis_outbox_publish_errors_total") + .description("Количество ошибок публикации в Kafka") + .register(meterRegistry); + this.publishTimer = Timer.builder("ordinis_kafka_publish_duration_seconds") + .description("Время публикации одного события в Kafka") + .register(meterRegistry); + } + + @Scheduled(fixedDelayString = "${ordinis.outbox.poll-interval-ms:1000}") + @Transactional + public void poll() { + List batch = repository.findUnpublished(PageRequest.of(0, batchSize)); + if (batch.isEmpty()) return; + + log.debug("Outbox poll: {} unpublished events", batch.size()); + + for (OutboxEvent event : batch) { + Timer.Sample sample = Timer.start(); + try { + String value = objectMapper.writeValueAsString(event.getPayload()); + SendResult result = kafkaTemplate + .send(event.getKafkaTopic(), event.getKafkaKey(), value) + .get(sendTimeoutMs, TimeUnit.MILLISECONDS); + + event.markPublished(); + repository.save(event); + publishedCounter.increment(); + sample.stop(publishTimer); + + log.debug("Published outbox event id={} topic={} partition={} offset={}", + event.getId(), + event.getKafkaTopic(), + result.getRecordMetadata().partition(), + result.getRecordMetadata().offset()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + markFailed(event, "Interrupted: " + e.getMessage()); + return; + } catch (ExecutionException | TimeoutException e) { + markFailed(event, e.getClass().getSimpleName() + ": " + e.getMessage()); + } catch (Exception e) { + markFailed(event, e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + } + + private void markFailed(OutboxEvent event, String error) { + log.warn("Failed to publish outbox event id={} topic={}: {}", + event.getId(), event.getKafkaTopic(), error); + event.markFailed(error); + repository.save(event); + failedCounter.increment(); + } +} diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxRecorder.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxRecorder.java new file mode 100644 index 0000000..3668f60 --- /dev/null +++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxRecorder.java @@ -0,0 +1,75 @@ +package cloud.nstart.terravault.ordinis.outbox; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent; +import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.micrometer.tracing.Tracer; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Запись события в outbox в той же транзакции что и изменение справочной + * записи. Гарантирует at-least-once публикацию (poller потом отправит в Kafka). + * + *

Tracer optional — если в кластере включён OTel, prefix trace_id/span_id + * прокинется в outbox row для корреляции. + */ +@Component +public class OutboxRecorder { + + private final OutboxEventRepository repository; + private final ObjectMapper objectMapper; + private final ObjectProvider tracerProvider; + + public OutboxRecorder( + OutboxEventRepository repository, + ObjectMapper objectMapper, + ObjectProvider tracerProvider) { + this.repository = repository; + this.objectMapper = objectMapper; + this.tracerProvider = tracerProvider; + } + + /** + * @param eventType строковый тип (например "RecordCreated") + * @param aggregateType "DictionaryRecord" или "DictionaryDefinition" + * @param aggregateId стабильный id аггрегата (UUID или dictionary name) + * @param dictionaryName имя словаря (для рутинга в Kafka топик и для логов) + * @param scope scope записи → определяет в какой топик + * @param bundle bundle (cuod, energy, ...) + * @param kafkaKey ключ публикации (обычно {@code :}) + * @param payload объект-payload (будет сериализован в JsonNode) + */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public OutboxEvent record( + String eventType, + String aggregateType, + String aggregateId, + String dictionaryName, + DataScope scope, + String bundle, + String kafkaKey, + Object payload) { + + JsonNode payloadJson = objectMapper.valueToTree(payload); + String topic = KafkaTopicResolver.topic(bundle, scope); + + OutboxEvent event = new OutboxEvent( + eventType, aggregateType, aggregateId, scope, payloadJson, topic, kafkaKey); + event.setDictionaryName(dictionaryName); + + Tracer tracer = tracerProvider.getIfAvailable(); + if (tracer != null) { + var span = tracer.currentSpan(); + if (span != null) { + event.setTraceId(span.context().traceId()); + event.setSpanId(span.context().spanId()); + } + } + + return repository.save(event); + } +} diff --git a/ordinis-rest-api/pom.xml b/ordinis-rest-api/pom.xml index c5831da..5c161d5 100644 --- a/ordinis-rest-api/pom.xml +++ b/ordinis-rest-api/pom.xml @@ -35,5 +35,17 @@ cloud.nstart.terravault.ordinis ordinis-outbox + + cloud.nstart.terravault.ordinis + ordinis-events-api + + + com.fasterxml.jackson.core + jackson-databind + + + io.micrometer + micrometer-tracing + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java new file mode 100644 index 0000000..3864474 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java @@ -0,0 +1,22 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CreateDictionaryRequest( + @NotBlank @Pattern(regexp = "^[a-z][a-z0-9_]{1,127}$") String name, + String displayName, + String description, + @NotNull DataScope scope, + @NotNull JsonNode schemaJson, + String schemaVersion, + String bundle, + List supportedLocales, + String defaultLocale) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateRecordRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateRecordRequest.java new file mode 100644 index 0000000..0314b25 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateRecordRequest.java @@ -0,0 +1,16 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.OffsetDateTime; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CreateRecordRequest( + @NotBlank String businessKey, + @NotNull JsonNode data, + String geometryWkt, + OffsetDateTime validFrom, + OffsetDateTime validTo) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java new file mode 100644 index 0000000..069c679 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java @@ -0,0 +1,46 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DictionaryResponse( + UUID id, + String name, + String displayName, + String description, + DataScope scope, + JsonNode schemaJson, + String schemaVersion, + String bundle, + List supportedLocales, + String defaultLocale, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + String createdBy, + String updatedBy) { + + public static DictionaryResponse from(DictionaryDefinition d) { + return new DictionaryResponse( + d.getId(), + d.getName(), + d.getDisplayName(), + d.getDescription(), + d.getScope(), + d.getSchemaJson(), + d.getSchemaVersion(), + d.getBundle(), + d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()), + d.getDefaultLocale(), + d.getCreatedAt(), + d.getUpdatedAt(), + d.getCreatedBy(), + d.getUpdatedBy()); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/RecordResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/RecordResponse.java new file mode 100644 index 0000000..671875e --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/RecordResponse.java @@ -0,0 +1,45 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import org.locationtech.jts.io.WKTWriter; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecordResponse( + UUID id, + UUID dictionaryId, + String businessKey, + JsonNode data, + String geometryWkt, + DataScope dataScope, + OffsetDateTime validFrom, + OffsetDateTime validTo, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + String createdBy, + String updatedBy, + Long version) { + + public static RecordResponse from(DictionaryRecord r) { + String wkt = r.getGeometry() == null ? null : new WKTWriter().write(r.getGeometry()); + return new RecordResponse( + r.getId(), + r.getDictionaryId(), + r.getBusinessKey(), + r.getData(), + wkt, + r.getDataScope(), + r.getValidFrom(), + r.getValidTo(), + r.getCreatedAt(), + r.getUpdatedAt(), + r.getCreatedBy(), + r.getUpdatedBy(), + r.getVersion()); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/ApiErrorResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/ApiErrorResponse.java new file mode 100644 index 0000000..0cc7db4 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/ApiErrorResponse.java @@ -0,0 +1,27 @@ +package cloud.nstart.terravault.ordinis.restapi.error; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.OffsetDateTime; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiErrorResponse( + int status, + String code, + String message, + List fieldErrors, + String traceId, + OffsetDateTime timestamp) { + + public record FieldError(String path, String code, String message) {} + + public static ApiErrorResponse of(int status, String code, String message, String traceId) { + return new ApiErrorResponse(status, code, message, null, traceId, OffsetDateTime.now()); + } + + public static ApiErrorResponse withFieldErrors( + int status, String code, String message, List errors, String traceId) { + return new ApiErrorResponse(status, code, message, errors, traceId, OffsetDateTime.now()); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..7baed2d --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java @@ -0,0 +1,61 @@ +package cloud.nstart.terravault.ordinis.restapi.error; + +import cloud.nstart.terravault.ordinis.validation.ValidationException; +import io.micrometer.tracing.Tracer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + private final ObjectProvider tracerProvider; + + public GlobalExceptionHandler(ObjectProvider tracerProvider) { + this.tracerProvider = tracerProvider; + } + + @ExceptionHandler(ValidationException.class) + public ResponseEntity onValidation(ValidationException e) { + List fields = e.getErrors().stream() + .map(err -> new ApiErrorResponse.FieldError(err.path(), err.code(), err.message())) + .toList(); + var body = ApiErrorResponse.withFieldErrors( + 422, "validation_failed", e.getMessage(), fields, currentTraceId()); + return ResponseEntity.unprocessableEntity().body(body); + } + + @ExceptionHandler(OrdinisException.class) + public ResponseEntity onOrdinis(OrdinisException e) { + var body = ApiErrorResponse.of(e.getStatus().value(), e.getCode(), e.getMessage(), currentTraceId()); + return ResponseEntity.status(e.getStatus()).body(body); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity onIllegalArgument(IllegalArgumentException e) { + var body = ApiErrorResponse.of(400, "bad_request", e.getMessage(), currentTraceId()); + return ResponseEntity.badRequest().body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity onGeneric(Exception e) { + log.error("Unhandled exception in REST API", e); + var body = ApiErrorResponse.of(500, "internal_error", "Internal server error", currentTraceId()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } + + private String currentTraceId() { + Tracer tracer = tracerProvider.getIfAvailable(); + if (tracer == null) return null; + var span = tracer.currentSpan(); + return span == null ? null : span.context().traceId(); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java new file mode 100644 index 0000000..2f1afcb --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java @@ -0,0 +1,34 @@ +package cloud.nstart.terravault.ordinis.restapi.error; + +import org.springframework.http.HttpStatus; + +public class OrdinisException extends RuntimeException { + + private final HttpStatus status; + private final String code; + + public OrdinisException(HttpStatus status, String code, String message) { + super(message); + this.status = status; + this.code = code; + } + + public HttpStatus getStatus() { return status; } + public String getCode() { return code; } + + public static OrdinisException notFound(String code, String message) { + return new OrdinisException(HttpStatus.NOT_FOUND, code, message); + } + + public static OrdinisException conflict(String code, String message) { + return new OrdinisException(HttpStatus.CONFLICT, code, message); + } + + public static OrdinisException badRequest(String code, String message) { + return new OrdinisException(HttpStatus.BAD_REQUEST, code, message); + } + + public static OrdinisException forbidden(String code, String message) { + return new OrdinisException(HttpStatus.FORBIDDEN, code, message); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java new file mode 100644 index 0000000..bc4f40c --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java @@ -0,0 +1,139 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +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 cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +public class DictionaryDefinitionService { + + private final DictionaryDefinitionRepository repository; + private final OutboxRecorder outboxRecorder; + + public DictionaryDefinitionService( + DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) { + this.repository = repository; + this.outboxRecorder = outboxRecorder; + } + + @Transactional(readOnly = true) + public List findAll() { + return repository.findAll(); + } + + @Transactional(readOnly = true) + public DictionaryDefinition findByName(String name) { + return repository.findByName(name) + .orElseThrow(() -> OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + name)); + } + + @Transactional + public DictionaryDefinition create(CreateDictionaryRequest req) { + if (repository.existsByName(req.name())) { + throw OrdinisException.conflict( + "dictionary_already_exists", "Dictionary already exists: " + req.name()); + } + + var d = new DictionaryDefinition(UUID.randomUUID(), req.name(), req.scope(), req.schemaJson()); + d.setDisplayName(req.displayName()); + d.setDescription(req.description()); + if (req.schemaVersion() != null) d.setSchemaVersion(req.schemaVersion()); + if (req.bundle() != null) d.setBundle(req.bundle()); + if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) { + d.setSupportedLocales(req.supportedLocales().toArray(String[]::new)); + } + if (req.defaultLocale() != null) d.setDefaultLocale(req.defaultLocale()); + + var saved = repository.save(d); + + var event = new DictionaryDefinitionCreated( + saved.getId(), + saved.getName(), + saved.getDisplayName(), + saved.getDescription(), + toEventScope(saved.getScope()), + saved.getSchemaJson(), + saved.getSchemaVersion(), + saved.getBundle(), + saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()), + saved.getDefaultLocale(), + saved.getCreatedAt(), + saved.getCreatedBy()); + + var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event); + + outboxRecorder.record( + "DefinitionCreated", + "DictionaryDefinition", + saved.getId().toString(), + saved.getName(), + saved.getScope(), + saved.getBundle(), + "definition:" + saved.getName(), + envelope); + + return saved; + } + + @Transactional + public DictionaryDefinition updateSchema(String name, CreateDictionaryRequest req) { + var existing = findByName(name); + var prevSchema = existing.getSchemaJson(); + + existing.setDisplayName(req.displayName()); + existing.setDescription(req.description()); + existing.setSchemaJson(req.schemaJson()); + if (req.schemaVersion() != null) existing.setSchemaVersion(req.schemaVersion()); + if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) { + existing.setSupportedLocales(req.supportedLocales().toArray(String[]::new)); + } + if (req.defaultLocale() != null) existing.setDefaultLocale(req.defaultLocale()); + + var saved = repository.save(existing); + + var event = new DictionaryDefinitionUpdated( + saved.getId(), + saved.getName(), + saved.getDisplayName(), + saved.getDescription(), + toEventScope(saved.getScope()), + saved.getSchemaJson(), + prevSchema, + saved.getSchemaVersion(), + saved.getBundle(), + saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()), + saved.getDefaultLocale(), + saved.getUpdatedAt(), + saved.getUpdatedBy()); + + var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event); + + outboxRecorder.record( + "DefinitionUpdated", + "DictionaryDefinition", + saved.getId().toString(), + saved.getName(), + saved.getScope(), + saved.getBundle(), + "definition:" + saved.getName(), + envelope); + + return saved; + } + + private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope( + cloud.nstart.terravault.ordinis.domain.DataScope d) { + return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name()); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java new file mode 100644 index 0000000..3057c93 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java @@ -0,0 +1,210 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import cloud.nstart.terravault.ordinis.events.EventEnvelope; +import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated; +import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted; +import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated; +import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; +import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.validation.RecordValidator; +import cloud.nstart.terravault.ordinis.validation.ValidationException; +import cloud.nstart.terravault.ordinis.validation.ValidationResult; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Bitemporal write-side для записей. Все изменения через SERIALIZABLE + * транзакцию (FK enforcement + EXCLUSION CONSTRAINT защищают от race). + * + *

Update = "close current version" + "create new version" — никогда не + * UPDATE существующей строки in-place. Это обеспечивает Envers history + + * проверяемость в любой момент времени. + * + *

Outbox запись — в той же транзакции что и change. Гарантирует at-least-once. + */ +@Service +public class DictionaryRecordService { + + private final DictionaryRecordRepository repository; + private final DictionaryDefinitionService definitionService; + private final RecordValidator validator; + private final OutboxRecorder outbox; + + private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z"); + + public DictionaryRecordService( + DictionaryRecordRepository repository, + DictionaryDefinitionService definitionService, + RecordValidator validator, + OutboxRecorder outbox) { + this.repository = repository; + this.definitionService = definitionService; + this.validator = validator; + this.outbox = outbox; + } + + @Transactional(readOnly = true) + public Optional findActive(String dictionaryName, String businessKey, OffsetDateTime at) { + var d = definitionService.findByName(dictionaryName); + return repository.findActiveAt(d.getId(), businessKey, at); + } + + @Transactional(readOnly = true) + public List findHistory(String dictionaryName, String businessKey) { + var d = definitionService.findByName(dictionaryName); + return repository.findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(d.getId(), businessKey); + } + + @Transactional(isolation = Isolation.SERIALIZABLE) + public DictionaryRecord create(String dictionaryName, CreateRecordRequest req) { + var d = definitionService.findByName(dictionaryName); + validate(d, req); + + OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom(); + OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo(); + + if (repository.findActiveAt(d.getId(), req.businessKey(), validFrom).isPresent()) { + throw OrdinisException.conflict( + "record_already_active", + "Запись с business_key=" + req.businessKey() + " уже активна на " + validFrom + + ". Используйте PUT (update) для новой версии."); + } + + var record = new DictionaryRecord( + UUID.randomUUID(), + d.getId(), + req.businessKey(), + req.data(), + d.getScope(), + validFrom); + record.setValidTo(validTo); + record.setGeometry(parseWkt(req.geometryWkt())); + + var saved = repository.save(record); + publishCreated(d, saved); + return saved; + } + + @Transactional(isolation = Isolation.SERIALIZABLE) + public DictionaryRecord update(String dictionaryName, String businessKey, CreateRecordRequest req) { + var d = definitionService.findByName(dictionaryName); + validate(d, req); + + OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom(); + OffsetDateTime newValidTo = req.validTo() == null ? FAR_FUTURE : req.validTo(); + + var current = repository.findActiveAt(d.getId(), businessKey, newValidFrom) + .orElseThrow(() -> OrdinisException.notFound( + "record_not_active", + "Нет активной записи business_key=" + businessKey + " на " + newValidFrom)); + + var prevData = current.getData(); + UUID previousRecordId = current.getId(); + + current.setValidTo(newValidFrom); + repository.save(current); + + var next = new DictionaryRecord( + UUID.randomUUID(), + d.getId(), + businessKey, + req.data(), + d.getScope(), + newValidFrom); + next.setValidTo(newValidTo); + next.setGeometry(parseWkt(req.geometryWkt())); + var saved = repository.save(next); + + publishUpdated(d, saved, previousRecordId, prevData); + return saved; + } + + @Transactional(isolation = Isolation.SERIALIZABLE) + public void close(String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) { + var d = definitionService.findByName(dictionaryName); + OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt; + + var current = repository.findActiveAt(d.getId(), businessKey, when) + .orElseThrow(() -> OrdinisException.notFound( + "record_not_active", + "Нет активной записи business_key=" + businessKey + " на " + when)); + + current.setValidTo(when); + repository.save(current); + + publishDeleted(d, current, when, reason); + } + + // — internals — + + private void validate(DictionaryDefinition d, CreateRecordRequest req) { + ValidationResult vr = validator.validate(d, req.data()); + if (!vr.valid()) throw new ValidationException(vr.errors()); + } + + private Geometry parseWkt(String wkt) { + if (wkt == null || wkt.isBlank()) return null; + try { + var reader = new WKTReader(); + Geometry g = reader.read(wkt); + g.setSRID(4326); + return g; + } catch (ParseException e) { + throw OrdinisException.badRequest("invalid_wkt", "Invalid WKT: " + e.getMessage()); + } + } + + private void publishCreated(DictionaryDefinition d, DictionaryRecord r) { + String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry()); + var payload = new DictionaryRecordCreated( + r.getId(), r.getBusinessKey(), r.getData(), wkt, + r.getValidFrom(), r.getValidTo(), r.getCreatedAt(), r.getCreatedBy()); + var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload); + outbox.record( + "RecordCreated", "DictionaryRecord", r.getId().toString(), + d.getName(), d.getScope(), d.getBundle(), + d.getName() + ":" + r.getBusinessKey(), envelope); + } + + private void publishUpdated( + DictionaryDefinition d, DictionaryRecord r, + UUID prevId, com.fasterxml.jackson.databind.JsonNode prevData) { + String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry()); + var payload = new DictionaryRecordUpdated( + r.getId(), prevId, r.getBusinessKey(), r.getData(), prevData, wkt, + r.getValidFrom(), r.getValidTo(), r.getUpdatedAt(), r.getUpdatedBy()); + var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload); + outbox.record( + "RecordUpdated", "DictionaryRecord", r.getId().toString(), + d.getName(), d.getScope(), d.getBundle(), + d.getName() + ":" + r.getBusinessKey(), envelope); + } + + private void publishDeleted(DictionaryDefinition d, DictionaryRecord r, OffsetDateTime closedAt, String reason) { + var payload = new DictionaryRecordDeleted( + r.getId(), r.getBusinessKey(), closedAt, OffsetDateTime.now(), r.getUpdatedBy(), reason); + var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload); + outbox.record( + "RecordDeleted", "DictionaryRecord", r.getId().toString(), + d.getName(), d.getScope(), d.getBundle(), + d.getName() + ":" + r.getBusinessKey(), envelope); + } + + private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope( + cloud.nstart.terravault.ordinis.domain.DataScope d) { + return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name()); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java new file mode 100644 index 0000000..cc9b4a9 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java @@ -0,0 +1,52 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; +import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse; +import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/dictionaries") +public class DictionaryDefinitionController { + + private final DictionaryDefinitionService service; + + public DictionaryDefinitionController(DictionaryDefinitionService service) { + this.service = service; + } + + @GetMapping + public List list() { + return service.findAll().stream().map(DictionaryResponse::from).toList(); + } + + @GetMapping("/{name}") + public DictionaryResponse get(@PathVariable String name) { + return DictionaryResponse.from(service.findByName(name)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public DictionaryResponse create(@Valid @RequestBody CreateDictionaryRequest req) { + return DictionaryResponse.from(service.create(req)); + } + + @PutMapping("/{name}") + public DictionaryResponse update( + @PathVariable String name, + @Valid @RequestBody CreateDictionaryRequest req) { + return DictionaryResponse.from(service.updateSchema(name, req)); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java new file mode 100644 index 0000000..2a9af69 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java @@ -0,0 +1,79 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest; +import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.time.OffsetDateTime; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/dictionaries/{name}/records") +public class DictionaryRecordController { + + private final DictionaryRecordService service; + + public DictionaryRecordController(DictionaryRecordService service) { + this.service = service; + } + + @GetMapping("/{businessKey}") + public RecordResponse getActive( + @PathVariable String name, + @PathVariable String businessKey, + @RequestParam(required = false) OffsetDateTime at) { + OffsetDateTime when = at == null ? OffsetDateTime.now() : at; + return service.findActive(name, businessKey, when) + .map(RecordResponse::from) + .orElseThrow(() -> OrdinisException.notFound( + "record_not_active", + "Нет активной записи business_key=" + businessKey + " на " + when)); + } + + @GetMapping("/{businessKey}/history") + public List getHistory( + @PathVariable String name, + @PathVariable String businessKey) { + return service.findHistory(name, businessKey).stream().map(RecordResponse::from).toList(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public RecordResponse create( + @PathVariable String name, + @Valid @RequestBody CreateRecordRequest req) { + return RecordResponse.from(service.create(name, req)); + } + + @PutMapping("/{businessKey}") + public RecordResponse update( + @PathVariable String name, + @PathVariable String businessKey, + @Valid @RequestBody CreateRecordRequest req) { + return RecordResponse.from(service.update(name, businessKey, req)); + } + + @DeleteMapping("/{businessKey}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void close( + @PathVariable String name, + @PathVariable String businessKey, + @RequestParam(required = false) OffsetDateTime at, + @RequestParam(required = false) String reason) { + service.close(name, businessKey, at, reason); + } +} 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 new file mode 100644 index 0000000..6b19ac1 --- /dev/null +++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java @@ -0,0 +1,144 @@ +package cloud.nstart.terravault.ordinis.validation; + +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaValidatorsConfig; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Двухступенчатая валидация записи: + * + *

    + *
  1. Стандартный JSON Schema (Draft-7) через networknt — типы, обязательные + * поля, форматы, etc.
  2. + *
  3. Custom правило для полей с extension {@code x-localized: true}: значение + * должно быть object {@code {locale: text}}, обязательно содержит + * {@code default_locale} справочника, ключи matchать pattern локали.
  4. + *
+ */ +@Component +public class RecordValidator { + + private static final Pattern LOCALE_TAG = Pattern.compile("^[a-z]{2}-[A-Z]{2}$"); + + private final JsonSchemaFactory schemaFactory = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); + + /** + * Валидирует {@code data} запись справочника против определения. + * Возвращает {@link ValidationResult}, не бросает. + */ + public ValidationResult validate(DictionaryDefinition definition, JsonNode data) { + List errors = new ArrayList<>(); + + SchemaValidatorsConfig config = SchemaValidatorsConfig.builder().build(); + JsonSchema schema = schemaFactory.getSchema(definition.getSchemaJson(), config); + + Set messages = schema.validate(data); + for (ValidationMessage m : messages) { + errors.add(new ValidationError( + m.getInstanceLocation().toString(), + m.getCode(), + m.getMessage())); + } + + Set supportedLocales = new HashSet<>(List.of(definition.getSupportedLocales())); + String defaultLocale = definition.getDefaultLocale(); + walkLocalizedFields(definition.getSchemaJson(), data, "", supportedLocales, defaultLocale, errors); + + return errors.isEmpty() ? ValidationResult.ok() : ValidationResult.failed(errors); + } + + /** + * Рекурсивный обход schema. Для каждого {@code properties[name]} с + * {@code x-localized: true} — проверяет значение в data. + */ + private void walkLocalizedFields( + JsonNode schema, + JsonNode data, + String path, + Set supported, + String defaultLocale, + List errors) { + + if (schema == null || !schema.isObject()) return; + + JsonNode properties = schema.get("properties"); + if (properties == null || !properties.isObject()) return; + + Iterator> it = properties.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + String fieldName = entry.getKey(); + JsonNode fieldSchema = entry.getValue(); + String fieldPath = path.isEmpty() ? "/" + fieldName : path + "/" + fieldName; + + JsonNode dataField = data == null ? null : data.get(fieldName); + + JsonNode xLocalized = fieldSchema.get("x-localized"); + if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) { + validateLocalizedValue(fieldPath, dataField, supported, defaultLocale, errors); + } else if (fieldSchema.has("properties")) { + walkLocalizedFields(fieldSchema, dataField, fieldPath, supported, defaultLocale, errors); + } + } + } + + private void validateLocalizedValue( + String path, + JsonNode value, + 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)")); + return; + } + if (!value.isObject()) { + errors.add(new ValidationError(path, "x-localized.not_object", + "Localized field must be an object {locale: text}")); + return; + } + + boolean hasDefault = false; + Iterator keys = value.fieldNames(); + while (keys.hasNext()) { + String locale = keys.next(); + if (!LOCALE_TAG.matcher(locale).matches()) { + errors.add(new ValidationError(path + "/" + locale, "x-localized.invalid_tag", + "Locale tag must match pattern xx-XX (BCP 47), got: " + locale)); + continue; + } + if (!supported.contains(locale)) { + errors.add(new ValidationError(path + "/" + locale, "x-localized.unsupported_locale", + "Locale " + locale + " is not in supported_locales of dictionary")); + continue; + } + JsonNode text = value.get(locale); + if (text == null || !text.isTextual() || text.asText().isBlank()) { + errors.add(new ValidationError(path + "/" + locale, "x-localized.empty_value", + "Locale " + locale + " value must be non-empty string")); + } + if (locale.equals(defaultLocale)) hasDefault = true; + } + + if (!hasDefault) { + errors.add(new ValidationError(path, "x-localized.missing_default", + "Default locale '" + defaultLocale + "' is required")); + } + } +} diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationError.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationError.java new file mode 100644 index 0000000..74be302 --- /dev/null +++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationError.java @@ -0,0 +1,8 @@ +package cloud.nstart.terravault.ordinis.validation; + +public record ValidationError(String path, String code, String message) { + + public static ValidationError of(String path, String code, String message) { + return new ValidationError(path, code, message); + } +} diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationException.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationException.java new file mode 100644 index 0000000..568b58a --- /dev/null +++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationException.java @@ -0,0 +1,17 @@ +package cloud.nstart.terravault.ordinis.validation; + +import java.util.List; + +public class ValidationException extends RuntimeException { + + private final List errors; + + public ValidationException(List errors) { + super("Validation failed with " + errors.size() + " error(s)"); + this.errors = List.copyOf(errors); + } + + public List getErrors() { + return errors; + } +} diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationResult.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationResult.java new file mode 100644 index 0000000..f977582 --- /dev/null +++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationResult.java @@ -0,0 +1,14 @@ +package cloud.nstart.terravault.ordinis.validation; + +import java.util.List; + +public record ValidationResult(boolean valid, List errors) { + + public static ValidationResult ok() { + return new ValidationResult(true, List.of()); + } + + public static ValidationResult failed(List errors) { + return new ValidationResult(false, List.copyOf(errors)); + } +}