feat(domain,events): bitemporal entities + i18n-aware definitions + sealed event API

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<T> 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, / отвечает
This commit is contained in:
Zimin A.N.
2026-05-03 12:10:36 +03:00
parent d1c8d5755e
commit b778832f49
26 changed files with 1000 additions and 0 deletions
@@ -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:
+8
View File
@@ -19,6 +19,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-63</artifactId>
@@ -27,6 +31,10 @@
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-envers</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-spatial</artifactId>
</dependency>
<dependency>
<groupId>net.postgis</groupId>
<artifactId>postgis-jdbc</artifactId>
@@ -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<DataScope> all() {
return Set.of(values());
}
}
@@ -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; }
}
@@ -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<AuditLog, Long> {
List<AuditLog> findByDictionaryIdOrderByEventTimeDesc(UUID dictionaryId);
List<AuditLog> findByRecordIdOrderByEventTimeDesc(UUID recordId);
List<AuditLog> findByUserIdOrderByEventTimeDesc(String userId);
List<AuditLog> findByEventTimeBetweenOrderByEventTimeDesc(OffsetDateTime from, OffsetDateTime to);
}
@@ -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 операций.
*
* <p>{@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<String> auditorAware() {
return () -> Optional.of("system");
}
}
@@ -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; }
}
@@ -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<DictionaryDefinition, UUID> {
Optional<DictionaryDefinition> findByName(String name);
List<DictionaryDefinition> findByBundle(String bundle);
List<DictionaryDefinition> findByScope(DataScope scope);
boolean existsByName(String name);
}
@@ -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;
}
}
@@ -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<IdempotencyKey, String> {
@Modifying
@Transactional
@Query("DELETE FROM IdempotencyKey k WHERE k.expiresAt < :now")
int deleteExpired(@Param("now") OffsetDateTime now);
}
@@ -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; }
}
@@ -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<OutboxEvent, Long> {
/**
* 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<OutboxEvent> findUnpublished(Pageable pageable);
long countByPublishedAtIsNull();
}
@@ -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 справочной записи.
*
* <p>Две оси истории:
* <ul>
* <li><b>Transaction time</b> — Hibernate Envers ({@code @Audited}). Когда мы
* <i>знали</i> о факте.</li>
* <li><b>Effective time</b> — {@code valid_from}/{@code valid_to}. Когда факт
* <i>действует</i> (например запись действует с 2027-01-01 но введена сегодня).</li>
* </ul>
*
* 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; }
}
@@ -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<DictionaryRecord, UUID> {
/**
* Все версии записи по бизнес-ключу (включая прошлые и будущие). Sorted by valid_from desc.
*/
List<DictionaryRecord> 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<DictionaryRecord> 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<DictionaryRecord> findActiveByDictionaryAndScopes(
@Param("dictionaryId") UUID dictionaryId,
@Param("allowedScopes") List<DataScope> allowedScopes,
@Param("at") OffsetDateTime at);
}
+4
View File
@@ -19,5 +19,9 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,13 @@
package cloud.nstart.terravault.ordinis.events;
/**
* Mirror domain {@code DataScope} но без зависимости на JPA. События — публичный
* API, consumers не должны тянуть domain entities ради enum.
*
* <p>Значения должны соответствовать {@code domain.DataScope}.
*/
public enum DataScope {
PUBLIC,
INTERNAL,
RESTRICTED
}
@@ -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.
*
* <p>Schema versioning: {@code schemaVersion} = semver shared DTO модуля
* (ordinis-events-api). Consumer проверяет совместимость через major version.
*
* @param <T> тип payload (DictionaryRecordEvent, DictionaryDefinitionEvent)
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record EventEnvelope<T>(
UUID eventId,
OffsetDateTime occurredAt,
String schemaVersion,
String bundle,
String dictionaryName,
DataScope dataScope,
String traceId,
String spanId,
String causedBy,
T payload) {
public static <T> EventEnvelope<T> 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);
}
}
@@ -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() {}
}
@@ -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<String> supportedLocales,
String defaultLocale,
OffsetDateTime createdAt,
String createdBy) implements DictionaryDefinitionEvent {}
@@ -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();
}
@@ -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<String> supportedLocales,
String defaultLocale,
OffsetDateTime updatedAt,
String updatedBy) implements DictionaryDefinitionEvent {}
@@ -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 сам сплющивает по нужной локали).
*
* <p>{@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 {}
@@ -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 {}
@@ -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();
}
@@ -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.
*
* <p>{@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 {}
@@ -28,6 +28,12 @@
<column name="bundle" type="VARCHAR(64)" defaultValue="cuod">
<constraints nullable="false"/>
</column>
<column name="supported_locales" type="TEXT[]" defaultValueComputed="ARRAY['ru-RU']::TEXT[]">
<constraints nullable="false"/>
</column>
<column name="default_locale" type="VARCHAR(10)" defaultValue="ru-RU">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>