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:
@@ -69,6 +69,7 @@ spring:
|
|||||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||||
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
|
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
|
||||||
- org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
|
- org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
|
||||||
|
- org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
|
||||||
|
|
||||||
otel:
|
otel:
|
||||||
sdk:
|
sdk:
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.hypersistence</groupId>
|
<groupId>io.hypersistence</groupId>
|
||||||
<artifactId>hypersistence-utils-hibernate-63</artifactId>
|
<artifactId>hypersistence-utils-hibernate-63</artifactId>
|
||||||
@@ -27,6 +31,10 @@
|
|||||||
<groupId>org.hibernate.orm</groupId>
|
<groupId>org.hibernate.orm</groupId>
|
||||||
<artifactId>hibernate-envers</artifactId>
|
<artifactId>hibernate-envers</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-spatial</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>net.postgis</groupId>
|
<groupId>net.postgis</groupId>
|
||||||
<artifactId>postgis-jdbc</artifactId>
|
<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -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; }
|
||||||
|
}
|
||||||
+18
@@ -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);
|
||||||
|
}
|
||||||
+32
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -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; }
|
||||||
|
}
|
||||||
+19
@@ -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);
|
||||||
|
}
|
||||||
+63
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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);
|
||||||
|
}
|
||||||
+125
@@ -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; }
|
||||||
|
}
|
||||||
+18
@@ -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();
|
||||||
|
}
|
||||||
+133
@@ -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; }
|
||||||
|
}
|
||||||
+51
@@ -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);
|
||||||
|
}
|
||||||
@@ -19,5 +19,9 @@
|
|||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
<artifactId>jackson-annotations</artifactId>
|
<artifactId>jackson-annotations</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
+13
@@ -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
|
||||||
|
}
|
||||||
+46
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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() {}
|
||||||
|
}
|
||||||
+24
@@ -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 {}
|
||||||
+17
@@ -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();
|
||||||
|
}
|
||||||
+29
@@ -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 {}
|
||||||
+25
@@ -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 {}
|
||||||
+19
@@ -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 {}
|
||||||
+24
@@ -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();
|
||||||
|
}
|
||||||
+26
@@ -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 {}
|
||||||
+6
@@ -28,6 +28,12 @@
|
|||||||
<column name="bundle" type="VARCHAR(64)" defaultValue="cuod">
|
<column name="bundle" type="VARCHAR(64)" defaultValue="cuod">
|
||||||
<constraints nullable="false"/>
|
<constraints nullable="false"/>
|
||||||
</column>
|
</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()">
|
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
|
||||||
<constraints nullable="false"/>
|
<constraints nullable="false"/>
|
||||||
</column>
|
</column>
|
||||||
|
|||||||
Reference in New Issue
Block a user