From b778832f4957918af7f052fd0c06c42727601764 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sun, 3 May 2026 12:10:36 +0300 Subject: [PATCH] feat(domain,events): bitemporal entities + i18n-aware definitions + sealed event API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ordinis-domain: - DataScope enum (PUBLIC/INTERNAL/RESTRICTED) с Kafka topic suffix + visibility logic - DictionaryDefinition entity: scope, schema_json (JsonNode JSONB), schema_version, bundle, supported_locales (text[]), default_locale + Spring Data Auditing - DictionaryRecord entity: bitemporal (Envers tx-time + valid_from/valid_to), data JSONB, geometry (JTS via hibernate-spatial), data_scope. EXCLUSION CONSTRAINT enforced в БД (Liquibase 0003). - AuditLog entity для compliance (отдельно от Loki) - OutboxEvent entity: aggregate_*, kafka_topic/kafka_key, attempt_count, markPublished/markFailed, partial index idx_outbox_unpublished - IdempotencyKey entity для exactly-once семантики write API - Repositories: findActiveAt, findActiveByDictionaryAndScopes, findUnpublished - JpaAuditingConfig: ConditionalOnBean(DataSource) — dev profile без БД не активирует auditing, metamodel не строится ordinis-events-api: - Mirror DataScope enum (events не depend on domain) - EventEnvelope record: eventId, occurredAt, schemaVersion (semver), bundle, dictionaryName, scope, traceId, spanId, payload - Sealed DictionaryRecordEvent + records Created/Updated/Deleted - Sealed DictionaryDefinitionEvent + records Created/Updated - Jackson polymorphism через @JsonTypeInfo discriminator - Multi-locale payload: события возят ВСЕ локали (consumer сам сплющивает) i18n design: - В JSONB поле кодируется как { 'ru-RU': '...', 'en-US': '...' } - JSON Schema справочника помечает локализуемые поля extension x-localized:true - supported_locales + default_locale в dictionary_definitions - Read API будет negotiate через Accept-Language (RFC 7231) и сплющивать Validation: - mvn install all 11 modules: BUILD SUCCESS - ordinis-app boot dev profile: /actuator/health UP, / отвечает --- .../src/main/resources/application.yml | 1 + ordinis-domain/pom.xml | 8 ++ .../terravault/ordinis/domain/DataScope.java | 34 +++++ .../ordinis/domain/audit/AuditLog.java | 114 +++++++++++++++ .../domain/audit/AuditLogRepository.java | 18 +++ .../domain/config/JpaAuditingConfig.java | 32 +++++ .../definition/DictionaryDefinition.java | 122 ++++++++++++++++ .../DictionaryDefinitionRepository.java | 19 +++ .../domain/idempotency/IdempotencyKey.java | 63 +++++++++ .../idempotency/IdempotencyKeyRepository.java | 17 +++ .../ordinis/domain/outbox/OutboxEvent.java | 125 ++++++++++++++++ .../domain/outbox/OutboxEventRepository.java | 18 +++ .../domain/record/DictionaryRecord.java | 133 ++++++++++++++++++ .../record/DictionaryRecordRepository.java | 51 +++++++ ordinis-events-api/pom.xml | 4 + .../terravault/ordinis/events/DataScope.java | 13 ++ .../ordinis/events/EventEnvelope.java | 46 ++++++ .../ordinis/events/EventSchemaVersion.java | 12 ++ .../DictionaryDefinitionCreated.java | 24 ++++ .../definition/DictionaryDefinitionEvent.java | 17 +++ .../DictionaryDefinitionUpdated.java | 29 ++++ .../record/DictionaryRecordCreated.java | 25 ++++ .../record/DictionaryRecordDeleted.java | 19 +++ .../events/record/DictionaryRecordEvent.java | 24 ++++ .../record/DictionaryRecordUpdated.java | 26 ++++ .../changes/0002-dictionary-definitions.xml | 6 + 26 files changed, 1000 insertions(+) create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/DataScope.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKey.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKeyRepository.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEvent.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEventRepository.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecord.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/DataScope.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventEnvelope.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventSchemaVersion.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionCreated.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionUpdated.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordCreated.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordDeleted.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordEvent.java create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordUpdated.java diff --git a/ordinis-app/src/main/resources/application.yml b/ordinis-app/src/main/resources/application.yml index 92410b7..478ee89 100644 --- a/ordinis-app/src/main/resources/application.yml +++ b/ordinis-app/src/main/resources/application.yml @@ -69,6 +69,7 @@ spring: - 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 otel: sdk: diff --git a/ordinis-domain/pom.xml b/ordinis-domain/pom.xml index 3378adc..eab95ed 100644 --- a/ordinis-domain/pom.xml +++ b/ordinis-domain/pom.xml @@ -19,6 +19,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + com.fasterxml.jackson.core + jackson-databind + io.hypersistence hypersistence-utils-hibernate-63 @@ -27,6 +31,10 @@ org.hibernate.orm hibernate-envers + + org.hibernate.orm + hibernate-spatial + net.postgis postgis-jdbc diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/DataScope.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/DataScope.java new file mode 100644 index 0000000..22197d6 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/DataScope.java @@ -0,0 +1,34 @@ +package cloud.nstart.terravault.ordinis.domain; + +import java.util.Set; + +/** + * Видимость данных в линейке Ordinis. Часть Kafka topology (топик per scope) и + * read-API filtering. Значения соответствуют CHECK constraint в БД и enum в + * JSON Schema поля dictionary_definitions.scope. + */ +public enum DataScope { + + /** Открытые справочники (геопортал, публичные consumer'ы). */ + PUBLIC, + + /** Внутренние данные клиента (Альтум-internal). Не должны утекать наружу клиента. */ + INTERNAL, + + /** Узкий круг: операторы, audit, compliance. */ + RESTRICTED; + + public String topicSuffix() { + return name().toLowerCase(); + } + + public boolean visibleTo(DataScope consumerScope) { + if (consumerScope == this) return true; + if (consumerScope == RESTRICTED) return true; // restricted видит всё в своём bundle + return consumerScope == INTERNAL && this == PUBLIC; + } + + public static Set all() { + return Set.of(values()); + } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java new file mode 100644 index 0000000..6550c84 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java @@ -0,0 +1,114 @@ +package cloud.nstart.terravault.ordinis.domain.audit; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.Type; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Юридически значимый audit log. Source of truth для compliance, отдельно от + * технических логов в Loki. Retention — по регламенту ЦУОД. + */ +@Entity +@Table(name = "audit_log") +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "event_time", nullable = false) + private OffsetDateTime eventTime; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(name = "user_id", length = 128) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(name = "user_scope", length = 32) + private DataScope userScope; + + @Column(name = "dictionary_id") + private UUID dictionaryId; + + @Column(name = "dictionary_name", length = 128) + private String dictionaryName; + + @Column(name = "record_id") + private UUID recordId; + + @Column(name = "business_key", length = 256) + private String businessKey; + + @Column(name = "action", length = 32) + private String action; + + @Type(JsonType.class) + @Column(name = "payload_before", columnDefinition = "jsonb") + private JsonNode payloadBefore; + + @Type(JsonType.class) + @Column(name = "payload_after", columnDefinition = "jsonb") + private JsonNode payloadAfter; + + @Column(name = "trace_id", length = 64) + private String traceId; + + @Column(name = "request_id", length = 64) + private String requestId; + + @Column(name = "ip_address", columnDefinition = "inet") + private String ipAddress; + + @Column(name = "user_agent", columnDefinition = "TEXT") + private String userAgent; + + protected AuditLog() {} + + public Long getId() { return id; } + public OffsetDateTime getEventTime() { return eventTime; } + public String getEventType() { return eventType; } + public String getUserId() { return userId; } + public DataScope getUserScope() { return userScope; } + public UUID getDictionaryId() { return dictionaryId; } + public String getDictionaryName() { return dictionaryName; } + public UUID getRecordId() { return recordId; } + public String getBusinessKey() { return businessKey; } + public String getAction() { return action; } + public JsonNode getPayloadBefore() { return payloadBefore; } + public JsonNode getPayloadAfter() { return payloadAfter; } + public String getTraceId() { return traceId; } + public String getRequestId() { return requestId; } + public String getIpAddress() { return ipAddress; } + public String getUserAgent() { return userAgent; } + + public void setEventTime(OffsetDateTime v) { this.eventTime = v; } + public void setEventType(String v) { this.eventType = v; } + public void setUserId(String v) { this.userId = v; } + public void setUserScope(DataScope v) { this.userScope = v; } + public void setDictionaryId(UUID v) { this.dictionaryId = v; } + public void setDictionaryName(String v) { this.dictionaryName = v; } + public void setRecordId(UUID v) { this.recordId = v; } + public void setBusinessKey(String v) { this.businessKey = v; } + public void setAction(String v) { this.action = v; } + public void setPayloadBefore(JsonNode v) { this.payloadBefore = v; } + public void setPayloadAfter(JsonNode v) { this.payloadAfter = v; } + public void setTraceId(String v) { this.traceId = v; } + public void setRequestId(String v) { this.requestId = v; } + public void setIpAddress(String v) { this.ipAddress = v; } + public void setUserAgent(String v) { this.userAgent = v; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java new file mode 100644 index 0000000..2e8323d --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java @@ -0,0 +1,18 @@ +package cloud.nstart.terravault.ordinis.domain.audit; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public interface AuditLogRepository extends JpaRepository { + + List findByDictionaryIdOrderByEventTimeDesc(UUID dictionaryId); + + List findByRecordIdOrderByEventTimeDesc(UUID recordId); + + List findByUserIdOrderByEventTimeDesc(String userId); + + List findByEventTimeBetweenOrderByEventTimeDesc(OffsetDateTime from, OffsetDateTime to); +} 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 new file mode 100644 index 0000000..34e9e3b --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java @@ -0,0 +1,32 @@ +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.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import javax.sql.DataSource; +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 операций. + * + *

{@link ConditionalOnBean} на {@link DataSource} — без БД (dev smoke + * profile) auditing не активируется, JPA metamodel не строится. + */ +@Configuration +@ConditionalOnBean(DataSource.class) +@EnableJpaAuditing(auditorAwareRef = "auditorAware") +public class JpaAuditingConfig { + + @Bean + @ConditionalOnMissingBean(AuditorAware.class) + public AuditorAware auditorAware() { + return () -> Optional.of("system"); + } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java new file mode 100644 index 0000000..39c7bf2 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java @@ -0,0 +1,122 @@ +package cloud.nstart.terravault.ordinis.domain.definition; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.array.StringArrayType; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.Type; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Каталоговый entity справочника: имя, JSON Schema, scope, поддерживаемые локали. + * Read-mostly — обновления редкие (admin operation). Не Audited через Envers + * (история через audit_log + outbox). + */ +@Entity +@Table(name = "dictionary_definitions") +@EntityListeners(AuditingEntityListener.class) +public class DictionaryDefinition { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "name", nullable = false, unique = true, length = 128) + private String name; + + @Column(name = "display_name", length = 256) + private String displayName; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "scope", nullable = false, length = 32) + private DataScope scope; + + @Type(JsonType.class) + @Column(name = "schema_json", nullable = false, columnDefinition = "jsonb") + private JsonNode schemaJson; + + @Column(name = "schema_version", nullable = false, length = 32) + private String schemaVersion = "1.0.0"; + + @Column(name = "bundle", nullable = false, length = 64) + private String bundle = "cuod"; + + @Type(StringArrayType.class) + @Column(name = "supported_locales", nullable = false, columnDefinition = "text[]") + private String[] supportedLocales = {"ru-RU"}; + + @Column(name = "default_locale", nullable = false, length = 10) + private String defaultLocale = "ru-RU"; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @CreatedBy + @Column(name = "created_by", length = 128, updatable = false) + private String createdBy; + + @LastModifiedBy + @Column(name = "updated_by", length = 128) + private String updatedBy; + + @Version + @Column(name = "version") + private Long version; + + protected DictionaryDefinition() {} + + public DictionaryDefinition(UUID id, String name, DataScope scope, JsonNode schemaJson) { + this.id = id; + this.name = name; + this.scope = scope; + this.schemaJson = schemaJson; + } + + public UUID getId() { return id; } + public String getName() { return name; } + public String getDisplayName() { return displayName; } + public String getDescription() { return description; } + public DataScope getScope() { return scope; } + public JsonNode getSchemaJson() { return schemaJson; } + public String getSchemaVersion() { return schemaVersion; } + public String getBundle() { return bundle; } + public String[] getSupportedLocales() { return supportedLocales; } + public String getDefaultLocale() { return defaultLocale; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public String getCreatedBy() { return createdBy; } + public String getUpdatedBy() { return updatedBy; } + public Long getVersion() { return version; } + + public void setDisplayName(String v) { this.displayName = v; } + public void setDescription(String v) { this.description = v; } + public void setSchemaJson(JsonNode v) { this.schemaJson = v; } + public void setSchemaVersion(String v) { this.schemaVersion = v; } + public void setBundle(String v) { this.bundle = v; } + public void setSupportedLocales(String[] v) { this.supportedLocales = v; } + public void setDefaultLocale(String v) { this.defaultLocale = v; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java new file mode 100644 index 0000000..10343dc --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java @@ -0,0 +1,19 @@ +package cloud.nstart.terravault.ordinis.domain.definition; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface DictionaryDefinitionRepository extends JpaRepository { + + Optional findByName(String name); + + List findByBundle(String bundle); + + List findByScope(DataScope scope); + + boolean existsByName(String name); +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKey.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKey.java new file mode 100644 index 0000000..7270974 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKey.java @@ -0,0 +1,63 @@ +package cloud.nstart.terravault.ordinis.domain.idempotency; + +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.Type; + +import java.time.OffsetDateTime; + +/** + * Idempotency key для exactly-once семантики write API. Header + * {@code Idempotency-Key} от клиента кладётся сюда вместе с request hash и + * сохранённым response. На повторный запрос с тем же ключом возвращается тот + * же ответ, не повторяется side-effect (запись + outbox event). + */ +@Entity +@Table(name = "idempotency_keys") +public class IdempotencyKey { + + @Id + @Column(name = "key", nullable = false, length = 128) + private String key; + + @Column(name = "request_hash", nullable = false, length = 64) + private String requestHash; + + @Column(name = "response_status") + private Integer responseStatus; + + @Type(JsonType.class) + @Column(name = "response_body", columnDefinition = "jsonb") + private JsonNode responseBody; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + protected IdempotencyKey() {} + + public IdempotencyKey(String key, String requestHash, OffsetDateTime expiresAt) { + this.key = key; + this.requestHash = requestHash; + this.createdAt = OffsetDateTime.now(); + this.expiresAt = expiresAt; + } + + public String getKey() { return key; } + public String getRequestHash() { return requestHash; } + public Integer getResponseStatus() { return responseStatus; } + public JsonNode getResponseBody() { return responseBody; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getExpiresAt() { return expiresAt; } + + public void setResponse(int status, JsonNode body) { + this.responseStatus = status; + this.responseBody = body; + } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKeyRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKeyRepository.java new file mode 100644 index 0000000..b5aa795 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKeyRepository.java @@ -0,0 +1,17 @@ +package cloud.nstart.terravault.ordinis.domain.idempotency; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; + +public interface IdempotencyKeyRepository extends JpaRepository { + + @Modifying + @Transactional + @Query("DELETE FROM IdempotencyKey k WHERE k.expiresAt < :now") + int deleteExpired(@Param("now") OffsetDateTime now); +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEvent.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEvent.java new file mode 100644 index 0000000..fd74254 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEvent.java @@ -0,0 +1,125 @@ +package cloud.nstart.terravault.ordinis.domain.outbox; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.Type; + +import java.time.OffsetDateTime; + +/** + * Outbox row для надёжной публикации в Kafka. Записывается в той же транзакции + * что и dictionary_records change. {@code @Scheduled} poller (`ordinis-outbox`) + * читает unpublished, отправляет в правильный scope-топик, помечает published_at. + */ +@Entity +@Table(name = "outbox_events") +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(name = "aggregate_type", nullable = false, length = 64) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false, length = 256) + private String aggregateId; + + @Column(name = "dictionary_name", length = 128) + private String dictionaryName; + + @Enumerated(EnumType.STRING) + @Column(name = "data_scope", nullable = false, length = 32) + private DataScope dataScope; + + @Type(JsonType.class) + @Column(name = "payload", nullable = false, columnDefinition = "jsonb") + private JsonNode payload; + + @Column(name = "kafka_topic", nullable = false, length = 256) + private String kafkaTopic; + + @Column(name = "kafka_key", nullable = false, length = 512) + private String kafkaKey; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "published_at") + private OffsetDateTime publishedAt; + + @Column(name = "trace_id", length = 64) + private String traceId; + + @Column(name = "span_id", length = 64) + private String spanId; + + @Column(name = "attempt_count", nullable = false) + private Integer attemptCount = 0; + + @Column(name = "last_error", columnDefinition = "TEXT") + private String lastError; + + protected OutboxEvent() {} + + public OutboxEvent( + String eventType, + String aggregateType, + String aggregateId, + DataScope dataScope, + JsonNode payload, + String kafkaTopic, + String kafkaKey) { + this.eventType = eventType; + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.dataScope = dataScope; + this.payload = payload; + this.kafkaTopic = kafkaTopic; + this.kafkaKey = kafkaKey; + this.createdAt = OffsetDateTime.now(); + } + + public void markPublished() { + this.publishedAt = OffsetDateTime.now(); + this.lastError = null; + } + + public void markFailed(String error) { + this.attemptCount = (this.attemptCount == null ? 0 : this.attemptCount) + 1; + this.lastError = error; + } + + public Long getId() { return id; } + public String getEventType() { return eventType; } + public String getAggregateType() { return aggregateType; } + public String getAggregateId() { return aggregateId; } + public String getDictionaryName() { return dictionaryName; } + public DataScope getDataScope() { return dataScope; } + public JsonNode getPayload() { return payload; } + public String getKafkaTopic() { return kafkaTopic; } + public String getKafkaKey() { return kafkaKey; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getPublishedAt() { return publishedAt; } + public String getTraceId() { return traceId; } + public String getSpanId() { return spanId; } + public Integer getAttemptCount() { return attemptCount; } + public String getLastError() { return lastError; } + + public void setDictionaryName(String v) { this.dictionaryName = v; } + public void setTraceId(String v) { this.traceId = v; } + public void setSpanId(String v) { this.spanId = v; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEventRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEventRepository.java new file mode 100644 index 0000000..0fc740b --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,18 @@ +package cloud.nstart.terravault.ordinis.domain.outbox; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface OutboxEventRepository extends JpaRepository { + + /** + * Unpublished events для polling. Использует partial index {@code idx_outbox_unpublished}. + */ + @Query("SELECT o FROM OutboxEvent o WHERE o.publishedAt IS NULL ORDER BY o.id ASC") + List findUnpublished(Pageable pageable); + + long countByPublishedAtIsNull(); +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecord.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecord.java new file mode 100644 index 0000000..b0405e0 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecord.java @@ -0,0 +1,133 @@ +package cloud.nstart.terravault.ordinis.domain.record; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.Type; +import org.hibernate.envers.Audited; +import org.hibernate.envers.RelationTargetAuditMode; +import org.hibernate.type.SqlTypes; +import org.locationtech.jts.geom.Geometry; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Bitemporal entity справочной записи. + * + *

Две оси истории: + *

    + *
  • Transaction time — Hibernate Envers ({@code @Audited}). Когда мы + * знали о факте.
  • + *
  • Effective time — {@code valid_from}/{@code valid_to}. Когда факт + * действует (например запись действует с 2027-01-01 но введена сегодня).
  • + *
+ * + * EXCLUSION CONSTRAINT в БД (см. Liquibase 0003) гарантирует отсутствие overlap + * по {@code (dictionary_id, business_key, valid_from..valid_to)}. + */ +@Entity +@Table(name = "dictionary_records") +@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED) +@EntityListeners(AuditingEntityListener.class) +public class DictionaryRecord { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "dictionary_id", nullable = false) + private UUID dictionaryId; + + @Column(name = "business_key", nullable = false, length = 256) + private String businessKey; + + @Type(JsonType.class) + @Column(name = "data", nullable = false, columnDefinition = "jsonb") + private JsonNode data; + + @JdbcTypeCode(SqlTypes.GEOMETRY) + @Column(name = "geometry", columnDefinition = "geometry(Geometry, 4326)") + private Geometry geometry; + + @Enumerated(EnumType.STRING) + @Column(name = "data_scope", nullable = false, length = 32) + private DataScope dataScope; + + @Column(name = "valid_from", nullable = false) + private OffsetDateTime validFrom; + + @Column(name = "valid_to", nullable = false) + private OffsetDateTime validTo = OffsetDateTime.parse("9999-12-31T23:59:59Z"); + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @CreatedBy + @Column(name = "created_by", length = 128, updatable = false) + private String createdBy; + + @LastModifiedBy + @Column(name = "updated_by", length = 128) + private String updatedBy; + + @Version + @Column(name = "version") + private Long version; + + protected DictionaryRecord() {} + + public DictionaryRecord( + UUID id, + UUID dictionaryId, + String businessKey, + JsonNode data, + DataScope dataScope, + OffsetDateTime validFrom) { + this.id = id; + this.dictionaryId = dictionaryId; + this.businessKey = businessKey; + this.data = data; + this.dataScope = dataScope; + this.validFrom = validFrom; + } + + public UUID getId() { return id; } + public UUID getDictionaryId() { return dictionaryId; } + public String getBusinessKey() { return businessKey; } + public JsonNode getData() { return data; } + public Geometry getGeometry() { return geometry; } + public DataScope getDataScope() { return dataScope; } + public OffsetDateTime getValidFrom() { return validFrom; } + public OffsetDateTime getValidTo() { return validTo; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public String getCreatedBy() { return createdBy; } + public String getUpdatedBy() { return updatedBy; } + public Long getVersion() { return version; } + + public void setData(JsonNode v) { this.data = v; } + public void setGeometry(Geometry v) { this.geometry = v; } + public void setValidFrom(OffsetDateTime v) { this.validFrom = v; } + public void setValidTo(OffsetDateTime v) { this.validTo = v; } + public void setDataScope(DataScope v) { this.dataScope = v; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java new file mode 100644 index 0000000..97be205 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java @@ -0,0 +1,51 @@ +package cloud.nstart.terravault.ordinis.domain.record; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface DictionaryRecordRepository extends JpaRepository { + + /** + * Все версии записи по бизнес-ключу (включая прошлые и будущие). Sorted by valid_from desc. + */ + List findByDictionaryIdAndBusinessKeyOrderByValidFromDesc( + UUID dictionaryId, String businessKey); + + /** + * Активная версия в момент {@code at}. Использует EXCLUSION CONSTRAINT — гарантировано не больше одной. + */ + @Query(""" + SELECT r FROM DictionaryRecord r + WHERE r.dictionaryId = :dictionaryId + AND r.businessKey = :businessKey + AND r.validFrom <= :at + AND r.validTo > :at + """) + Optional findActiveAt( + @Param("dictionaryId") UUID dictionaryId, + @Param("businessKey") String businessKey, + @Param("at") OffsetDateTime at); + + /** + * Все активные записи в справочнике в момент {@code at}, отфильтрованные по доступным scope'ам + * для consumer'а. Используется read-api после locale negotiation. + */ + @Query(""" + SELECT r FROM DictionaryRecord r + WHERE r.dictionaryId = :dictionaryId + AND r.dataScope IN :allowedScopes + AND r.validFrom <= :at + AND r.validTo > :at + """) + List findActiveByDictionaryAndScopes( + @Param("dictionaryId") UUID dictionaryId, + @Param("allowedScopes") List allowedScopes, + @Param("at") OffsetDateTime at); +} diff --git a/ordinis-events-api/pom.xml b/ordinis-events-api/pom.xml index 56406e2..fe2501d 100644 --- a/ordinis-events-api/pom.xml +++ b/ordinis-events-api/pom.xml @@ -19,5 +19,9 @@ com.fasterxml.jackson.core jackson-annotations
+ + com.fasterxml.jackson.core + jackson-databind + diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/DataScope.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/DataScope.java new file mode 100644 index 0000000..a1aa854 --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/DataScope.java @@ -0,0 +1,13 @@ +package cloud.nstart.terravault.ordinis.events; + +/** + * Mirror domain {@code DataScope} но без зависимости на JPA. События — публичный + * API, consumers не должны тянуть domain entities ради enum. + * + *

Значения должны соответствовать {@code domain.DataScope}. + */ +public enum DataScope { + PUBLIC, + INTERNAL, + RESTRICTED +} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventEnvelope.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventEnvelope.java new file mode 100644 index 0000000..eb5272a --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventEnvelope.java @@ -0,0 +1,46 @@ +package cloud.nstart.terravault.ordinis.events; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Метаданные-обёртка вокруг payload каждого события. Едет в Kafka как value. + * + *

Schema versioning: {@code schemaVersion} = semver shared DTO модуля + * (ordinis-events-api). Consumer проверяет совместимость через major version. + * + * @param тип payload (DictionaryRecordEvent, DictionaryDefinitionEvent) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record EventEnvelope( + UUID eventId, + OffsetDateTime occurredAt, + String schemaVersion, + String bundle, + String dictionaryName, + DataScope dataScope, + String traceId, + String spanId, + String causedBy, + T payload) { + + public static EventEnvelope of( + String dictionaryName, + DataScope scope, + String bundle, + T payload) { + return new EventEnvelope<>( + UUID.randomUUID(), + OffsetDateTime.now(), + EventSchemaVersion.CURRENT, + bundle, + dictionaryName, + scope, + null, + null, + null, + payload); + } +} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventSchemaVersion.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventSchemaVersion.java new file mode 100644 index 0000000..773eb9d --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventSchemaVersion.java @@ -0,0 +1,12 @@ +package cloud.nstart.terravault.ordinis.events; + +/** + * Semver версии DTO модуля. Major bump = breaking change. Consumers должны + * проверять major перед deserialization. + */ +public final class EventSchemaVersion { + public static final String CURRENT = "1.0.0"; + public static final int MAJOR = 1; + + private EventSchemaVersion() {} +} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionCreated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionCreated.java new file mode 100644 index 0000000..fd505f0 --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionCreated.java @@ -0,0 +1,24 @@ +package cloud.nstart.terravault.ordinis.events.definition; + +import cloud.nstart.terravault.ordinis.events.DataScope; +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 DictionaryDefinitionCreated( + UUID definitionId, + String dictionaryName, + String displayName, + String description, + DataScope scope, + JsonNode schemaJson, + String schemaVersion, + String bundle, + List supportedLocales, + String defaultLocale, + OffsetDateTime createdAt, + String createdBy) implements DictionaryDefinitionEvent {} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java new file mode 100644 index 0000000..b322c4a --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java @@ -0,0 +1,17 @@ +package cloud.nstart.terravault.ordinis.events.definition; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = DictionaryDefinitionCreated.class, name = "DefinitionCreated"), + @JsonSubTypes.Type(value = DictionaryDefinitionUpdated.class, name = "DefinitionUpdated") +}) +public sealed interface DictionaryDefinitionEvent + permits DictionaryDefinitionCreated, DictionaryDefinitionUpdated { + + java.util.UUID definitionId(); + + String dictionaryName(); +} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionUpdated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionUpdated.java new file mode 100644 index 0000000..49506ba --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionUpdated.java @@ -0,0 +1,29 @@ +package cloud.nstart.terravault.ordinis.events.definition; + +import cloud.nstart.terravault.ordinis.events.DataScope; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Изменение определения справочника (schema, supported_locales и т.д.). Вместе + * с новой версией прилетает {@code previousSchemaJson} для diff. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DictionaryDefinitionUpdated( + UUID definitionId, + String dictionaryName, + String displayName, + String description, + DataScope scope, + JsonNode schemaJson, + JsonNode previousSchemaJson, + String schemaVersion, + String bundle, + List supportedLocales, + String defaultLocale, + OffsetDateTime updatedAt, + String updatedBy) implements DictionaryDefinitionEvent {} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordCreated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordCreated.java new file mode 100644 index 0000000..92e46b6 --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordCreated.java @@ -0,0 +1,25 @@ +package cloud.nstart.terravault.ordinis.events.record; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Создана новая версия записи справочника. {@code data} — полный multi-locale + * payload (consumer сам сплющивает по нужной локали). + * + *

{@code geometryWkt} — WKT представление геометрии (если есть). EWKT с SRID + * 4326 для совместимости с PostGIS. Может быть null. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DictionaryRecordCreated( + UUID recordId, + String businessKey, + JsonNode data, + String geometryWkt, + OffsetDateTime validFrom, + OffsetDateTime validTo, + OffsetDateTime createdAt, + String createdBy) implements DictionaryRecordEvent {} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordDeleted.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordDeleted.java new file mode 100644 index 0000000..1702e07 --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordDeleted.java @@ -0,0 +1,19 @@ +package cloud.nstart.terravault.ordinis.events.record; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Логическое удаление записи (точнее: закрытие текущей версии установкой + * valid_to). Реального DELETE из таблицы не происходит — для compliance/audit. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DictionaryRecordDeleted( + UUID recordId, + String businessKey, + OffsetDateTime closedAt, + OffsetDateTime deletedAt, + String deletedBy, + String reason) implements DictionaryRecordEvent {} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordEvent.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordEvent.java new file mode 100644 index 0000000..b6cebbb --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordEvent.java @@ -0,0 +1,24 @@ +package cloud.nstart.terravault.ordinis.events.record; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Sealed interface для всех событий по записям справочника. Jackson + * polymorphic deserialization через {@code "type"} discriminator. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = DictionaryRecordCreated.class, name = "RecordCreated"), + @JsonSubTypes.Type(value = DictionaryRecordUpdated.class, name = "RecordUpdated"), + @JsonSubTypes.Type(value = DictionaryRecordDeleted.class, name = "RecordDeleted") +}) +public sealed interface DictionaryRecordEvent + permits DictionaryRecordCreated, DictionaryRecordUpdated, DictionaryRecordDeleted { + + /** UUID dictionary_records.id. */ + java.util.UUID recordId(); + + /** Бизнес-ключ записи (стабильный across versions). */ + String businessKey(); +} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordUpdated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordUpdated.java new file mode 100644 index 0000000..14769fb --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordUpdated.java @@ -0,0 +1,26 @@ +package cloud.nstart.terravault.ordinis.events.record; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Обновление существующей записи. Modeling: новая версия (новый recordId, тот + * же businessKey) с новой valid_from. Старая версия получила valid_to=newRecord.validFrom. + * + *

{@code previousData} даёт consumer'у возможность сделать diff. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DictionaryRecordUpdated( + UUID recordId, + UUID previousRecordId, + String businessKey, + JsonNode data, + JsonNode previousData, + String geometryWkt, + OffsetDateTime validFrom, + OffsetDateTime validTo, + OffsetDateTime updatedAt, + String updatedBy) implements DictionaryRecordEvent {} diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml index 643d87a..0a559a5 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml @@ -28,6 +28,12 @@ + + + + + +