Init
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
package space.nstart.pcp.routepassportconsumer
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.kafka.annotation.EnableKafka
|
||||
|
||||
/**
|
||||
* Главный класс Spring Boot приложения.
|
||||
*
|
||||
* Приложение поднимает только инфраструктуру потребителя Kafka и сервис парсинга JSON.
|
||||
*/
|
||||
@ConfigurationPropertiesScan
|
||||
@SpringBootApplication
|
||||
@EnableKafka
|
||||
class RoutePassportConsumerApplication
|
||||
|
||||
/**
|
||||
* Точка входа приложения.
|
||||
*
|
||||
* @param args аргументы командной строки, которые передаются в Spring Boot.
|
||||
*/
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<RoutePassportConsumerApplication>(*args)
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package space.nstart.pcp.routepassportconsumer.config
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParseException
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import org.apache.kafka.clients.consumer.ConsumerConfig
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||
import org.apache.kafka.common.TopicPartition
|
||||
import org.apache.kafka.common.header.Headers
|
||||
import org.apache.kafka.common.header.internals.RecordHeader
|
||||
import org.apache.kafka.common.header.internals.RecordHeaders
|
||||
import org.apache.kafka.common.serialization.StringDeserializer
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
|
||||
import org.springframework.kafka.core.ConsumerFactory
|
||||
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.listener.ContainerProperties
|
||||
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
|
||||
import org.springframework.kafka.listener.DefaultErrorHandler
|
||||
import org.springframework.util.backoff.ExponentialBackOff
|
||||
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
|
||||
import tools.jackson.core.JacksonException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||
class KafkaConsumerConfig(
|
||||
@param:Value("\${spring.kafka.bootstrap-servers}")
|
||||
private val bootstrapServers: String,
|
||||
@param:Value("\${spring.kafka.consumer.group-id}")
|
||||
private val groupId: String,
|
||||
@param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
|
||||
private val autoOffsetReset: String,
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun consumerFactory(): ConsumerFactory<String, String> =
|
||||
DefaultKafkaConsumerFactory(
|
||||
mutableMapOf<String, Any>(
|
||||
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||
ConsumerConfig.GROUP_ID_CONFIG to groupId,
|
||||
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
|
||||
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
|
||||
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||
),
|
||||
)
|
||||
|
||||
@Bean
|
||||
fun kafkaListenerContainerFactory(
|
||||
kafkaErrorHandler: DefaultErrorHandler,
|
||||
): ConcurrentKafkaListenerContainerFactory<String, String> =
|
||||
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
|
||||
setConsumerFactory(consumerFactory())
|
||||
setCommonErrorHandler(kafkaErrorHandler)
|
||||
containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun deadLetterPublishingRecoverer(
|
||||
kafkaTemplate: KafkaTemplate<String, String>,
|
||||
kafkaTopicsProperties: KafkaTopicsProperties,
|
||||
): DeadLetterPublishingRecoverer =
|
||||
DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
|
||||
TopicPartition(kafkaTopicsProperties.inputDlq, record.partition())
|
||||
}.apply {
|
||||
addHeadersFunction { record, exception -> routePassportDlqHeaders(record, exception) }
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun kafkaErrorHandler(
|
||||
deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer,
|
||||
retryProperties: RoutePassportConsumerRetryProperties,
|
||||
): DefaultErrorHandler =
|
||||
DefaultErrorHandler(
|
||||
deadLetterPublishingRecoverer,
|
||||
routePassportConsumerBackOff(retryProperties),
|
||||
).apply {
|
||||
addNotRetryableExceptions(
|
||||
RoutePassportContractException::class.java,
|
||||
JacksonException::class.java,
|
||||
JsonProcessingException::class.java,
|
||||
JsonMappingException::class.java,
|
||||
JsonParseException::class.java,
|
||||
)
|
||||
setCommitRecovered(true)
|
||||
}
|
||||
|
||||
private fun routePassportConsumerBackOff(
|
||||
retryProperties: RoutePassportConsumerRetryProperties,
|
||||
): ExponentialBackOff =
|
||||
ExponentialBackOff().apply {
|
||||
initialInterval = retryProperties.backoffMs
|
||||
multiplier = RETRY_BACKOFF_MULTIPLIER
|
||||
maxInterval = retryProperties.maxBackoffMs
|
||||
maxAttempts = (retryProperties.maxAttempts - 1).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun routePassportDlqHeaders(
|
||||
record: ConsumerRecord<*, *>,
|
||||
exception: Exception,
|
||||
): Headers =
|
||||
RecordHeaders().apply {
|
||||
addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME)
|
||||
addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name)
|
||||
addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty())
|
||||
addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic())
|
||||
addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString())
|
||||
addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString())
|
||||
}
|
||||
|
||||
private fun Headers.addStringHeader(name: String, value: String) {
|
||||
add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8)))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_NAME = "pcp-route-processing-service"
|
||||
const val RETRY_BACKOFF_MULTIPLIER = 2.0
|
||||
|
||||
const val HEADER_SERVICE_NAME = "pcp-service-name"
|
||||
const val HEADER_EXCEPTION_CLASS = "pcp-exception-class"
|
||||
const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message"
|
||||
const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic"
|
||||
const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition"
|
||||
const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset"
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package space.nstart.pcp.routepassportconsumer.config
|
||||
|
||||
import org.apache.kafka.clients.producer.ProducerConfig
|
||||
import org.apache.kafka.common.serialization.StringSerializer
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.kafka.core.DefaultKafkaProducerFactory
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.core.ProducerFactory
|
||||
|
||||
/**
|
||||
* Явная producer-конфигурация для route-processing-service.
|
||||
*
|
||||
* В этом модуле Kafka producer bean'ы не поднимались автоконфигурацией,
|
||||
* поэтому template регистрируется явно.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||
class KafkaProducerConfig(
|
||||
@param:Value("\${spring.kafka.bootstrap-servers}")
|
||||
private val bootstrapServers: String,
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun producerFactory(): ProducerFactory<String, String> =
|
||||
DefaultKafkaProducerFactory(
|
||||
mutableMapOf<String, Any>(
|
||||
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
||||
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
||||
),
|
||||
)
|
||||
|
||||
@Bean
|
||||
fun kafkaTemplate(): KafkaTemplate<String, String> = KafkaTemplate(producerFactory())
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package space.nstart.pcp.routepassportconsumer.config
|
||||
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.validation.annotation.Validated
|
||||
|
||||
/**
|
||||
* Типизированные Kafka topic-настройки сервиса.
|
||||
*
|
||||
* Все значения приходят из spring-cloud-config-server и валидируются при старте,
|
||||
* чтобы сервис не запускался без обязательных имен входного и выходных topic'ов.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "app.kafka.topics")
|
||||
data class KafkaTopicsProperties(
|
||||
|
||||
/** Topic, из которого сервис читает входящие паспорта маршрутов. */
|
||||
@field:NotBlank
|
||||
val input: String = "",
|
||||
|
||||
/** DLQ topic для входящих паспортов маршрутов, которые не удалось обработать. */
|
||||
@field:NotBlank
|
||||
val inputDlq: String = "",
|
||||
|
||||
/** Topic, в который сервис будет публиковать сообщение об обработанном маршруте. */
|
||||
@field:NotBlank
|
||||
val processedRoute: String = "",
|
||||
|
||||
/** Topic, в который сервис будет публиковать сообщение геопривязки маршрута. */
|
||||
@field:NotBlank
|
||||
val routeGeoreference: String = "",
|
||||
)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package space.nstart.pcp.routepassportconsumer.config
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI
|
||||
import io.swagger.v3.oas.models.info.Info
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* Базовая OpenAPI-конфигурация для route-processing-service.
|
||||
*/
|
||||
@Configuration
|
||||
class OpenApiConfig {
|
||||
@Bean
|
||||
fun routeProcessingOpenApi(): OpenAPI {
|
||||
return OpenAPI()
|
||||
.info(
|
||||
Info()
|
||||
.title("PCP Route Processing Service API")
|
||||
.description("REST API сервиса обработки паспортов маршрутов PCP.")
|
||||
.version("v1"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package space.nstart.pcp.routepassportconsumer.config
|
||||
|
||||
import jakarta.validation.constraints.Min
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.validation.annotation.Validated
|
||||
|
||||
/**
|
||||
* Настройки bounded retry для input Kafka consumer route passport flow.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "app.route-passport.consumer.retry")
|
||||
data class RoutePassportConsumerRetryProperties(
|
||||
|
||||
/** Максимальное количество попыток обработки, включая первичную delivery. */
|
||||
@field:Min(1)
|
||||
val maxAttempts: Long = 3,
|
||||
|
||||
/** Начальная задержка retry в миллисекундах. */
|
||||
@field:Min(0)
|
||||
val backoffMs: Long = 1_000,
|
||||
|
||||
/** Максимальная задержка между retry в миллисекундах. */
|
||||
@field:Min(0)
|
||||
val maxBackoffMs: Long = 30_000,
|
||||
)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package space.nstart.pcp.routepassportconsumer.controller
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/route-processing")
|
||||
@Tag(name = "Route Processing", description = "Тестовые endpoint'ы сервиса обработки маршрутов")
|
||||
class RouteProcessingTestController(
|
||||
private val kafkaTemplate: KafkaTemplate<String, String>,
|
||||
private val kafkaTopicsProperties: KafkaTopicsProperties,
|
||||
) {
|
||||
|
||||
@GetMapping("/test")
|
||||
@Operation(summary = "Проверка доступности сервиса")
|
||||
fun test(): Map<String, String> = mapOf(
|
||||
"status" to "ok",
|
||||
"service" to "pcp-route-processing-service",
|
||||
)
|
||||
|
||||
@PostMapping("/test/send-passport-message")
|
||||
@Operation(summary = "Отправить тестовый паспорт маршрута в Kafka")
|
||||
fun sendPassportMessage(): Map<String, String> {
|
||||
val payload = ClassPathResource("test-passport-message.json")
|
||||
.inputStream
|
||||
.readAllBytes()
|
||||
.toString(StandardCharsets.UTF_8)
|
||||
|
||||
kafkaTemplate.send(kafkaTopicsProperties.input, payload)
|
||||
|
||||
return mapOf(
|
||||
"status" to "sent",
|
||||
"topic" to kafkaTopicsProperties.input,
|
||||
"resource" to "classpath:test-passport-message.json",
|
||||
)
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package space.nstart.pcp.routepassportconsumer.entity
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import jakarta.persistence.Version
|
||||
import org.hibernate.annotations.JdbcTypeCode
|
||||
import org.hibernate.type.SqlTypes
|
||||
import tools.jackson.databind.JsonNode
|
||||
import java.math.BigDecimal
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "route_passports")
|
||||
class RoutePassportEntity(
|
||||
@Id
|
||||
@Column(name = "route_id", nullable = false)
|
||||
var routeId: UUID,
|
||||
|
||||
@Column(name = "route_name_full", nullable = false, columnDefinition = "TEXT")
|
||||
var routeNameFull: String,
|
||||
|
||||
@Column(name = "route_name_short", nullable = false, length = 255)
|
||||
var routeNameShort: String,
|
||||
|
||||
@Column(name = "ka_short", nullable = false, length = 64)
|
||||
var kaShort: String,
|
||||
|
||||
@Column(name = "ka_full", nullable = false, length = 255)
|
||||
var kaFull: String,
|
||||
|
||||
@Column(name = "orbit_number", nullable = false)
|
||||
var orbitNumber: Long,
|
||||
|
||||
@Column(name = "orbit_state", nullable = false, length = 64)
|
||||
var orbitState: String,
|
||||
|
||||
@Column(name = "interval_begin", nullable = false)
|
||||
var intervalBegin: LocalDateTime,
|
||||
|
||||
@Column(name = "interval_end", nullable = false)
|
||||
var intervalEnd: LocalDateTime,
|
||||
|
||||
@Column(name = "roll_angle_min", nullable = false)
|
||||
var rollAngleMin: BigDecimal,
|
||||
|
||||
@Column(name = "roll_angle_max", nullable = false)
|
||||
var rollAngleMax: BigDecimal,
|
||||
|
||||
@Column(name = "visir_angle_min", nullable = false)
|
||||
var visirAngleMin: BigDecimal,
|
||||
|
||||
@Column(name = "visir_angle_max", nullable = false)
|
||||
var visirAngleMax: BigDecimal,
|
||||
|
||||
@Column(name = "resolution_range", nullable = false)
|
||||
var resolutionRange: BigDecimal,
|
||||
|
||||
@Column(name = "resolution_azimuth", nullable = false)
|
||||
var resolutionAzimuth: BigDecimal,
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "polarisation", nullable = false, columnDefinition = "jsonb")
|
||||
var polarisation: JsonNode,
|
||||
|
||||
@Column(name = "processing_level", nullable = false, length = 64)
|
||||
var processingLevel: String,
|
||||
|
||||
@Column(name = "geometry_wkt", nullable = false, columnDefinition = "TEXT")
|
||||
var geometryWkt: String,
|
||||
|
||||
@Column(name = "route_status", nullable = false, length = 32)
|
||||
var routeStatus: String,
|
||||
|
||||
@Column(name = "trace_id", length = 255)
|
||||
var traceId: String? = null,
|
||||
|
||||
@Column(name = "correlation_id", length = 255)
|
||||
var correlationId: String? = null,
|
||||
|
||||
@Column(name = "source_topic", length = 255)
|
||||
var sourceTopic: String? = null,
|
||||
|
||||
@Column(name = "source_partition")
|
||||
var sourcePartition: Int? = null,
|
||||
|
||||
@Column(name = "source_offset")
|
||||
var sourceOffset: Long? = null,
|
||||
|
||||
@Column(name = "source_key", length = 255)
|
||||
var sourceKey: String? = null,
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "raw_payload", columnDefinition = "jsonb")
|
||||
var rawPayload: JsonNode? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime,
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
var version: Long = 0,
|
||||
)
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package space.nstart.pcp.routepassportconsumer.entity
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import org.hibernate.annotations.JdbcTypeCode
|
||||
import org.hibernate.type.SqlTypes
|
||||
import tools.jackson.databind.JsonNode
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "route_passport_outbox")
|
||||
class RoutePassportOutboxEntity(
|
||||
@Id
|
||||
@Column(name = "id", nullable = false)
|
||||
var id: UUID,
|
||||
|
||||
@Column(name = "route_id", nullable = false)
|
||||
var routeId: UUID,
|
||||
|
||||
@Column(name = "event_type", nullable = false, length = 128)
|
||||
var eventType: String,
|
||||
|
||||
@Column(name = "topic", nullable = false, length = 255)
|
||||
var topic: String,
|
||||
|
||||
@Column(name = "message_key", nullable = false, length = 255)
|
||||
var messageKey: String,
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "payload", nullable = false, columnDefinition = "jsonb")
|
||||
var payload: JsonNode,
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "headers", columnDefinition = "jsonb")
|
||||
var headers: JsonNode? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 32)
|
||||
var status: RoutePassportOutboxStatus,
|
||||
|
||||
@Column(name = "attempts", nullable = false)
|
||||
var attempts: Int,
|
||||
|
||||
@Column(name = "max_attempts", nullable = false)
|
||||
var maxAttempts: Int,
|
||||
|
||||
@Column(name = "next_attempt_at", nullable = false)
|
||||
var nextAttemptAt: LocalDateTime,
|
||||
|
||||
@Column(name = "locked_until")
|
||||
var lockedUntil: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "last_error", columnDefinition = "TEXT")
|
||||
var lastError: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime,
|
||||
|
||||
@Column(name = "published_at")
|
||||
var publishedAt: LocalDateTime? = null,
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package space.nstart.pcp.routepassportconsumer.entity
|
||||
|
||||
/**
|
||||
* Состояние события outbox в жизненном цикле публикации.
|
||||
*/
|
||||
enum class RoutePassportOutboxStatus {
|
||||
PENDING,
|
||||
PUBLISHED,
|
||||
FAILED,
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package space.nstart.pcp.routepassportconsumer.exception
|
||||
|
||||
class RoutePassportContractException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause)
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package space.nstart.pcp.routepassportconsumer.listener
|
||||
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.kafka.support.Acknowledgment
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
|
||||
import space.nstart.pcp.routepassportconsumer.service.RoutePassportIngestionService
|
||||
import space.nstart.pcp.routepassportconsumer.service.RoutePassportJsonParserService
|
||||
import space.nstart.pcp.routepassportconsumer.service.RoutePassportSourceRecordMetadata
|
||||
|
||||
/**
|
||||
* Kafka listener, который получает JSON паспорта маршрута и передаёт его в транзакционный ingestion.
|
||||
*/
|
||||
@Component
|
||||
class RoutePassportMessageListener(
|
||||
|
||||
/** Сервис парсинга входного JSON сообщения в итоговый DTO. */
|
||||
private val routePassportJsonParserService: RoutePassportJsonParserService,
|
||||
|
||||
/** Сервис транзакционного сохранения паспорта маршрута и outbox-событий. */
|
||||
private val routePassportIngestionService: RoutePassportIngestionService,
|
||||
|
||||
/** Набор Kafka topic'ов, который приходит из config server и валидируется при старте. */
|
||||
val kafkaTopicsProperties: KafkaTopicsProperties,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
/**
|
||||
* Обрабатывает входное сообщение Kafka.
|
||||
*
|
||||
* Offset подтверждается только после успешного возврата из transactional ingestion.
|
||||
*/
|
||||
@KafkaListener(
|
||||
topics = ["#{__listener.kafkaTopicsProperties.input}"],
|
||||
groupId = "\${spring.kafka.consumer.group-id}",
|
||||
)
|
||||
fun listen(
|
||||
record: ConsumerRecord<String, String>,
|
||||
acknowledgment: Acknowledgment,
|
||||
) {
|
||||
val rawPayload = record.value()
|
||||
val sourceRecord = RoutePassportSourceRecordMetadata(
|
||||
topic = record.topic(),
|
||||
partition = record.partition(),
|
||||
offset = record.offset(),
|
||||
key = record.key(),
|
||||
)
|
||||
|
||||
try {
|
||||
log.debug(
|
||||
"Kafka record received: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}",
|
||||
sourceRecord.topic,
|
||||
sourceRecord.partition,
|
||||
sourceRecord.offset,
|
||||
sourceRecord.key,
|
||||
rawPayload.length,
|
||||
)
|
||||
|
||||
val parsedMessage = routePassportJsonParserService.parse(rawPayload)
|
||||
routePassportIngestionService.ingest(
|
||||
parsedMessage = parsedMessage,
|
||||
rawPayload = rawPayload,
|
||||
sourceRecord = sourceRecord,
|
||||
)
|
||||
|
||||
// Transactional ingest commits before returning; only then the Kafka offset is acknowledged.
|
||||
acknowledgment.acknowledge()
|
||||
|
||||
log.info(
|
||||
"Route passport ingested: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}, routeId={}, traceId={}, correlationId={}",
|
||||
sourceRecord.topic,
|
||||
sourceRecord.partition,
|
||||
sourceRecord.offset,
|
||||
sourceRecord.key,
|
||||
rawPayload.length,
|
||||
parsedMessage.routePassportDto.routeId,
|
||||
parsedMessage.metadata.traceId,
|
||||
parsedMessage.metadata.correlationId,
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
log.error(
|
||||
"Failed to process Kafka record: sourceTopic={}, partition={}, offset={}, key={}, error={}",
|
||||
sourceRecord.topic,
|
||||
sourceRecord.partition,
|
||||
sourceRecord.offset,
|
||||
sourceRecord.key,
|
||||
exception.message,
|
||||
exception,
|
||||
)
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package space.nstart.pcp.routepassportconsumer.repository
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
|
||||
import java.util.UUID
|
||||
|
||||
interface RoutePassportOutboxRepository : JpaRepository<RoutePassportOutboxEntity, UUID> {
|
||||
fun existsByRouteIdAndEventType(routeId: UUID, eventType: String): Boolean
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
SELECT *
|
||||
FROM route_passport_outbox
|
||||
WHERE status = 'PENDING'
|
||||
AND next_attempt_at <= now()
|
||||
AND (locked_until IS NULL OR locked_until < now())
|
||||
ORDER BY created_at
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
nativeQuery = true,
|
||||
)
|
||||
fun findDueForUpdateSkipLocked(@Param("limit") limit: Int): List<RoutePassportOutboxEntity>
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package space.nstart.pcp.routepassportconsumer.repository
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity
|
||||
import java.util.UUID
|
||||
|
||||
interface RoutePassportRepository : JpaRepository<RoutePassportEntity, UUID>
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package space.nstart.pcp.routepassportconsumer.service
|
||||
|
||||
import org.locationtech.jts.geom.Coordinate
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.geom.GeometryFactory
|
||||
import org.locationtech.jts.geom.LinearRing
|
||||
import org.locationtech.jts.geom.Polygon
|
||||
import org.locationtech.jts.io.WKTWriter
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto
|
||||
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
|
||||
import tools.jackson.databind.JsonNode
|
||||
|
||||
/**
|
||||
* Конвертирует поддерживаемую GeoJSON geometry входного паспорта маршрута в WKT.
|
||||
*/
|
||||
@Service
|
||||
open class RouteGeometryConverter {
|
||||
private val geometryFactory = GeometryFactory()
|
||||
private val wktWriter = WKTWriter()
|
||||
|
||||
open fun convert(geometry: RawGeoJsonGeometryDto?): String {
|
||||
if (geometry == null) {
|
||||
throw RoutePassportContractException("Отсутствует обязательное поле geometry")
|
||||
}
|
||||
|
||||
val jtsGeometry = when (geometry.type) {
|
||||
"Polygon" -> toPolygon(geometry.coordinates, fieldName = "geometry.coordinates")
|
||||
"MultiPolygon" -> toMultiPolygon(geometry.coordinates)
|
||||
else -> throw RoutePassportContractException("Неподдерживаемый тип geometry: ${geometry.type}")
|
||||
}
|
||||
|
||||
return writeCompactWkt(jtsGeometry)
|
||||
}
|
||||
|
||||
private fun toMultiPolygon(coordinatesNode: JsonNode): Geometry {
|
||||
requireArray(coordinatesNode, "geometry.coordinates")
|
||||
|
||||
if (coordinatesNode.size() == 0) {
|
||||
throw RoutePassportContractException("MultiPolygon geometry.coordinates не должен быть пустым")
|
||||
}
|
||||
|
||||
val polygons = coordinatesNode.mapIndexed { index, polygonNode ->
|
||||
toPolygon(polygonNode, fieldName = "geometry.coordinates[$index]")
|
||||
}.toTypedArray()
|
||||
|
||||
return geometryFactory.createMultiPolygon(polygons)
|
||||
}
|
||||
|
||||
private fun toPolygon(
|
||||
coordinatesNode: JsonNode,
|
||||
fieldName: String,
|
||||
): Polygon {
|
||||
requireArray(coordinatesNode, fieldName)
|
||||
|
||||
if (coordinatesNode.size() == 0) {
|
||||
throw RoutePassportContractException("Polygon $fieldName должен содержать хотя бы один linear ring")
|
||||
}
|
||||
|
||||
val shell = toLinearRing(coordinatesNode[0], "$fieldName[0]")
|
||||
val holes = coordinatesNode.drop(1).mapIndexed { index, ringNode ->
|
||||
toLinearRing(ringNode, "$fieldName[${index + 1}]")
|
||||
}.toTypedArray()
|
||||
|
||||
return try {
|
||||
geometryFactory.createPolygon(shell, holes)
|
||||
} catch (exception: IllegalArgumentException) {
|
||||
throw RoutePassportContractException("Некорректная структура Polygon $fieldName", exception)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toLinearRing(
|
||||
ringNode: JsonNode,
|
||||
fieldName: String,
|
||||
): LinearRing {
|
||||
requireArray(ringNode, fieldName)
|
||||
|
||||
if (ringNode.size() < MIN_LINEAR_RING_POINTS) {
|
||||
throw RoutePassportContractException(
|
||||
"Linear ring $fieldName должен содержать минимум $MIN_LINEAR_RING_POINTS точки",
|
||||
)
|
||||
}
|
||||
|
||||
val coordinates = ringNode.mapIndexed { index, coordinateNode ->
|
||||
toCoordinate(coordinateNode, "$fieldName[$index]")
|
||||
}.toTypedArray()
|
||||
|
||||
if (!coordinates.first().equals2D(coordinates.last())) {
|
||||
throw RoutePassportContractException("Linear ring $fieldName должен быть замкнут")
|
||||
}
|
||||
|
||||
return try {
|
||||
geometryFactory.createLinearRing(coordinates)
|
||||
} catch (exception: IllegalArgumentException) {
|
||||
throw RoutePassportContractException("Некорректная структура linear ring $fieldName", exception)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toCoordinate(
|
||||
coordinateNode: JsonNode,
|
||||
fieldName: String,
|
||||
): Coordinate {
|
||||
requireArray(coordinateNode, fieldName)
|
||||
|
||||
if (coordinateNode.size() < MIN_COORDINATE_VALUES) {
|
||||
throw RoutePassportContractException("Точка $fieldName должна содержать минимум две координаты")
|
||||
}
|
||||
|
||||
if (!coordinateNode[0].isNumber || !coordinateNode[1].isNumber) {
|
||||
throw RoutePassportContractException("Точка $fieldName должна содержать числовые longitude и latitude")
|
||||
}
|
||||
|
||||
return try {
|
||||
Coordinate(
|
||||
coordinateNode[0].decimalValue().toDouble(),
|
||||
coordinateNode[1].decimalValue().toDouble(),
|
||||
)
|
||||
} catch (exception: RuntimeException) {
|
||||
throw RoutePassportContractException("Точка $fieldName содержит некорректные координаты", exception)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireArray(
|
||||
node: JsonNode,
|
||||
fieldName: String,
|
||||
) {
|
||||
if (!node.isArray) {
|
||||
throw RoutePassportContractException("Поле $fieldName должно быть массивом GeoJSON")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeCompactWkt(geometry: Geometry): String =
|
||||
when (geometry.geometryType) {
|
||||
"Polygon" -> wktWriter.write(geometry).removePrefix("POLYGON ").let { "POLYGON$it" }
|
||||
"MultiPolygon" -> wktWriter.write(geometry).removePrefix("MULTIPOLYGON ").let { "MULTIPOLYGON$it" }
|
||||
else -> wktWriter.write(geometry)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_COORDINATE_VALUES = 2
|
||||
const val MIN_LINEAR_RING_POINTS = 4
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package space.nstart.pcp.routepassportconsumer.service
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
|
||||
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
|
||||
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity
|
||||
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
|
||||
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
|
||||
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository
|
||||
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportRepository
|
||||
import tools.jackson.databind.JsonNode
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Сохраняет разобранный route passport и outbox-события в одной DB transaction.
|
||||
*/
|
||||
@Service
|
||||
class RoutePassportIngestionService(
|
||||
private val routePassportRepository: RoutePassportRepository,
|
||||
private val routePassportOutboxRepository: RoutePassportOutboxRepository,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val kafkaTopicsProperties: KafkaTopicsProperties,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun ingest(
|
||||
parsedMessage: ParsedRoutePassportMessage,
|
||||
rawPayload: String,
|
||||
sourceRecord: RoutePassportSourceRecordMetadata,
|
||||
) {
|
||||
val now = LocalDateTime.now()
|
||||
val routePassportDto = parsedMessage.routePassportDto
|
||||
|
||||
val routePassportEntity = routePassportRepository.findById(routePassportDto.routeId)
|
||||
.map { existingEntity ->
|
||||
existingEntity.applyRoutePassport(
|
||||
routePassportDto = routePassportDto,
|
||||
parsedMessage = parsedMessage,
|
||||
rawPayload = rawPayload,
|
||||
sourceRecord = sourceRecord,
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
.orElseGet {
|
||||
routePassportDto.toEntity(
|
||||
parsedMessage = parsedMessage,
|
||||
rawPayload = rawPayload,
|
||||
sourceRecord = sourceRecord,
|
||||
now = now,
|
||||
)
|
||||
}
|
||||
|
||||
routePassportRepository.save(routePassportEntity)
|
||||
|
||||
createOutboxIfAbsent(
|
||||
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
|
||||
topic = kafkaTopicsProperties.processedRoute,
|
||||
parsedMessage = parsedMessage,
|
||||
now = now,
|
||||
)
|
||||
createOutboxIfAbsent(
|
||||
eventType = PcpKafkaEvent.RouteGeoRefEvent,
|
||||
topic = kafkaTopicsProperties.routeGeoreference,
|
||||
parsedMessage = parsedMessage,
|
||||
now = now,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createOutboxIfAbsent(
|
||||
eventType: PcpKafkaEvent,
|
||||
topic: String,
|
||||
parsedMessage: ParsedRoutePassportMessage,
|
||||
now: LocalDateTime,
|
||||
) {
|
||||
val routeId = parsedMessage.routePassportDto.routeId
|
||||
val eventTypeName = eventType.name
|
||||
|
||||
if (routePassportOutboxRepository.existsByRouteIdAndEventType(routeId, eventTypeName)) {
|
||||
return
|
||||
}
|
||||
|
||||
routePassportOutboxRepository.save(
|
||||
RoutePassportOutboxEntity(
|
||||
id = UUID.randomUUID(),
|
||||
routeId = routeId,
|
||||
eventType = eventTypeName,
|
||||
topic = topic,
|
||||
messageKey = routeId.toString(),
|
||||
payload = buildOutboxPayload(eventType, parsedMessage),
|
||||
headers = null,
|
||||
status = RoutePassportOutboxStatus.PENDING,
|
||||
attempts = 0,
|
||||
maxAttempts = 10,
|
||||
nextAttemptAt = now,
|
||||
lockedUntil = null,
|
||||
lastError = null,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
publishedAt = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildOutboxPayload(
|
||||
eventType: PcpKafkaEvent,
|
||||
parsedMessage: ParsedRoutePassportMessage,
|
||||
): JsonNode {
|
||||
val message = KafkaMessage(
|
||||
type = eventType,
|
||||
data = parsedMessage.routePassportDto,
|
||||
traceId = parsedMessage.metadata.traceId,
|
||||
correlationId = parsedMessage.metadata.correlationId,
|
||||
group = "pcp",
|
||||
).apply {
|
||||
source = "route-processing"
|
||||
}
|
||||
|
||||
return objectMapper.valueToTree(message)
|
||||
}
|
||||
|
||||
private fun RoutePassportEntity.applyRoutePassport(
|
||||
routePassportDto: RoutePassportDto,
|
||||
parsedMessage: ParsedRoutePassportMessage,
|
||||
rawPayload: String,
|
||||
sourceRecord: RoutePassportSourceRecordMetadata,
|
||||
updatedAt: LocalDateTime,
|
||||
): RoutePassportEntity {
|
||||
routeNameFull = routePassportDto.routeNameFull
|
||||
routeNameShort = routePassportDto.routeNameShort
|
||||
kaShort = routePassportDto.kaShort
|
||||
kaFull = routePassportDto.kaFull
|
||||
orbitNumber = routePassportDto.orbitNumber
|
||||
orbitState = routePassportDto.orbitState
|
||||
intervalBegin = routePassportDto.intervalBegin
|
||||
intervalEnd = routePassportDto.intervalEnd
|
||||
rollAngleMin = routePassportDto.rollAngle.min
|
||||
rollAngleMax = routePassportDto.rollAngle.max
|
||||
visirAngleMin = routePassportDto.visirAngle.min
|
||||
visirAngleMax = routePassportDto.visirAngle.max
|
||||
resolutionRange = routePassportDto.resolutionRange
|
||||
resolutionAzimuth = routePassportDto.resolutionAzimuth
|
||||
polarisation = objectMapper.valueToTree(routePassportDto.polarisation)
|
||||
processingLevel = routePassportDto.processingLevel
|
||||
geometryWkt = routePassportDto.geometry
|
||||
routeStatus = routePassportDto.routeStatus.name
|
||||
traceId = parsedMessage.metadata.traceId
|
||||
correlationId = parsedMessage.metadata.correlationId
|
||||
sourceTopic = sourceRecord.topic
|
||||
sourcePartition = sourceRecord.partition
|
||||
sourceOffset = sourceRecord.offset
|
||||
sourceKey = sourceRecord.key
|
||||
this.rawPayload = objectMapper.readTree(rawPayload)
|
||||
this.updatedAt = updatedAt
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun RoutePassportDto.toEntity(
|
||||
parsedMessage: ParsedRoutePassportMessage,
|
||||
rawPayload: String,
|
||||
sourceRecord: RoutePassportSourceRecordMetadata,
|
||||
now: LocalDateTime,
|
||||
): RoutePassportEntity =
|
||||
RoutePassportEntity(
|
||||
routeId = routeId,
|
||||
routeNameFull = routeNameFull,
|
||||
routeNameShort = routeNameShort,
|
||||
kaShort = kaShort,
|
||||
kaFull = kaFull,
|
||||
orbitNumber = orbitNumber,
|
||||
orbitState = orbitState,
|
||||
intervalBegin = intervalBegin,
|
||||
intervalEnd = intervalEnd,
|
||||
rollAngleMin = rollAngle.min,
|
||||
rollAngleMax = rollAngle.max,
|
||||
visirAngleMin = visirAngle.min,
|
||||
visirAngleMax = visirAngle.max,
|
||||
resolutionRange = resolutionRange,
|
||||
resolutionAzimuth = resolutionAzimuth,
|
||||
polarisation = objectMapper.valueToTree(polarisation),
|
||||
processingLevel = processingLevel,
|
||||
geometryWkt = geometry,
|
||||
routeStatus = routeStatus.name,
|
||||
traceId = parsedMessage.metadata.traceId,
|
||||
correlationId = parsedMessage.metadata.correlationId,
|
||||
sourceTopic = sourceRecord.topic,
|
||||
sourcePartition = sourceRecord.partition,
|
||||
sourceOffset = sourceRecord.offset,
|
||||
sourceKey = sourceRecord.key,
|
||||
rawPayload = objectMapper.readTree(rawPayload),
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package space.nstart.pcp.routepassportconsumer.service
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RawFeaturePassportJsonDto
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RawPassportEnvelopeDto
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata
|
||||
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
|
||||
import tools.jackson.core.JacksonException
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.math.BigDecimal
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Сервис, который парсит JSON паспорта маршрута в итоговый DTO.
|
||||
*/
|
||||
@Service
|
||||
class RoutePassportJsonParserService(
|
||||
|
||||
/** Jackson ObjectMapper, который выполняет базовую десериализацию JSON. */
|
||||
private val objectMapper: ObjectMapper,
|
||||
|
||||
/** Converter GeoJSON geometry входного паспорта маршрута в WKT. */
|
||||
private val routeGeometryConverter: RouteGeometryConverter,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Парсит входной JSON и возвращает готовый DTO.
|
||||
*
|
||||
* @param json исходное Kafka-сообщение envelope-формата с полезной нагрузкой в поле `data`.
|
||||
* @return DTO с уже преобразованными полями.
|
||||
*/
|
||||
fun parse(json: String): ParsedRoutePassportMessage {
|
||||
val rawEnvelope: RawPassportEnvelopeDto = try {
|
||||
objectMapper.readValue(json, RawPassportEnvelopeDto::class.java)
|
||||
} catch (exception: JacksonException) {
|
||||
throw RoutePassportContractException("Некорректный JSON contract паспорта маршрута", exception)
|
||||
}
|
||||
val routePassportDto = rawEnvelope.data.toRoutePassportDto()
|
||||
|
||||
return ParsedRoutePassportMessage(
|
||||
metadata = RoutePassportMessageMetadata(
|
||||
traceId = rawEnvelope.traceId,
|
||||
correlationId = rawEnvelope.correlationId,
|
||||
),
|
||||
routePassportDto = routePassportDto,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует feature из envelope-сообщения в бизнес DTO.
|
||||
*/
|
||||
private fun RawFeaturePassportJsonDto.toRoutePassportDto(): RoutePassportDto {
|
||||
val routePassportDto = RoutePassportDto(
|
||||
routeId = routeId ?: UUID.nameUUIDFromBytes(id.toByteArray(StandardCharsets.UTF_8)),
|
||||
kaShort = properties.missionId,
|
||||
kaFull = properties.platform,
|
||||
routeNameFull = id,
|
||||
routeNameShort = collection,
|
||||
orbitNumber = properties.orbitNumber.toLong(),
|
||||
orbitState = properties.orbitState,
|
||||
intervalBegin = LocalDateTime.parse(properties.intervalBegin),
|
||||
intervalEnd = LocalDateTime.parse(properties.intervalEnd),
|
||||
rollAngle = properties.rollAngle.toAngleRangeDto(fieldName = "rollAngle"),
|
||||
visirAngle = properties.visirAngle.toAngleRangeDto(fieldName = "visirAngle"),
|
||||
resolutionRange = properties.resolutionRange.toBigDecimal(),
|
||||
resolutionAzimuth = properties.resolutionAzimuth.toBigDecimal(),
|
||||
polarisation = properties.polarisation,
|
||||
processingLevel = properties.processingLevel,
|
||||
geometry = routeGeometryConverter.convert(geometry),
|
||||
)
|
||||
|
||||
return routePassportDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует список чисел в DTO диапазона.
|
||||
*
|
||||
* @receiver список значений диапазона.
|
||||
* @param fieldName имя поля для текста ошибки.
|
||||
* @return DTO диапазона с минимальным и максимальным значением.
|
||||
*/
|
||||
private fun List<BigDecimal>.toAngleRangeDto(fieldName: String): AngleRangeDto {
|
||||
if (this.isEmpty()) {
|
||||
throw RoutePassportContractException("Поле $fieldName не должно быть пустым")
|
||||
}
|
||||
|
||||
/** Минимальное значение диапазона. */
|
||||
val minValue: BigDecimal = this.minOrNull()
|
||||
?: throw RoutePassportContractException("Не удалось определить минимум для поля $fieldName")
|
||||
|
||||
/** Максимальное значение диапазона. */
|
||||
val maxValue: BigDecimal = this.maxOrNull()
|
||||
?: throw RoutePassportContractException("Не удалось определить максимум для поля $fieldName")
|
||||
|
||||
/** DTO диапазона после вычисления минимума и максимума. */
|
||||
val angleRangeDto: AngleRangeDto = AngleRangeDto(
|
||||
min = minValue,
|
||||
max = maxValue,
|
||||
)
|
||||
|
||||
return angleRangeDto
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package space.nstart.pcp.routepassportconsumer.service
|
||||
|
||||
import org.apache.kafka.clients.producer.ProducerRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.support.SendResult
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
|
||||
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
|
||||
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
/**
|
||||
* Сервис публикации обработанного маршрута в Kafka.
|
||||
*/
|
||||
@Service
|
||||
class RoutePassportKafkaPublisherService(
|
||||
/** ObjectMapper сериализует итоговый DTO в JSON перед отправкой. */
|
||||
private val objectMapper: ObjectMapper,
|
||||
|
||||
/** KafkaTemplate отправляет одно и то же сообщение в несколько topic'ов. */
|
||||
private val kafkaTemplate: KafkaTemplate<String, String>,
|
||||
|
||||
/** Набор Kafka topic'ов, в которые публикуется результат обработки. */
|
||||
private val kafkaTopicsProperties: KafkaTopicsProperties,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
/**
|
||||
* Публикует полный RoutePassportDto в оба выходных topic'а.
|
||||
*/
|
||||
fun publish(parsedMessage: ParsedRoutePassportMessage) {
|
||||
val routePassportDto = parsedMessage.routePassportDto
|
||||
|
||||
log.info(
|
||||
"Publishing route passport: routeNameFull={}, processedRouteTopic={}, routeGeoreferenceTopic={}",
|
||||
routePassportDto.routeNameFull,
|
||||
kafkaTopicsProperties.processedRoute,
|
||||
kafkaTopicsProperties.routeGeoreference,
|
||||
)
|
||||
|
||||
sendWithLogging(
|
||||
topic = kafkaTopicsProperties.processedRoute,
|
||||
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
|
||||
payload = buildPayload(
|
||||
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
|
||||
parsedMessage = parsedMessage,
|
||||
),
|
||||
traceId = parsedMessage.metadata.traceId,
|
||||
routeNameFull = routePassportDto.routeNameFull,
|
||||
)
|
||||
sendWithLogging(
|
||||
topic = kafkaTopicsProperties.routeGeoreference,
|
||||
eventType = PcpKafkaEvent.RouteGeoRefEvent,
|
||||
payload = buildPayload(
|
||||
eventType = PcpKafkaEvent.RouteGeoRefEvent,
|
||||
parsedMessage = parsedMessage,
|
||||
),
|
||||
traceId = parsedMessage.metadata.traceId,
|
||||
routeNameFull = routePassportDto.routeNameFull,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildPayload(eventType: PcpKafkaEvent, parsedMessage: ParsedRoutePassportMessage): String {
|
||||
val message = KafkaMessage(
|
||||
type = eventType,
|
||||
data = parsedMessage.routePassportDto,
|
||||
traceId = parsedMessage.metadata.traceId,
|
||||
correlationId = parsedMessage.metadata.correlationId,
|
||||
group = "pcp",
|
||||
).apply {
|
||||
source = "route-processing"
|
||||
}
|
||||
|
||||
return objectMapper.writeValueAsString(message)
|
||||
}
|
||||
|
||||
private fun sendWithLogging(
|
||||
topic: String,
|
||||
eventType: PcpKafkaEvent,
|
||||
payload: String,
|
||||
traceId: String,
|
||||
routeNameFull: String,
|
||||
) {
|
||||
val record = ProducerRecord(topic, traceId, payload)
|
||||
record.headers().add("type", eventType.name.toByteArray(StandardCharsets.UTF_8))
|
||||
|
||||
val future: CompletableFuture<SendResult<String, String>> = kafkaTemplate.send(record)
|
||||
future.whenComplete { result, exception ->
|
||||
if (exception == null) {
|
||||
log.info(
|
||||
"Kafka message published: routeNameFull={}, topic={}, type={}, partition={}, offset={}",
|
||||
routeNameFull,
|
||||
topic,
|
||||
eventType,
|
||||
result.recordMetadata.partition(),
|
||||
result.recordMetadata.offset(),
|
||||
)
|
||||
} else {
|
||||
log.error(
|
||||
"Failed to publish kafka message: routeNameFull={}, topic={}, type={}",
|
||||
routeNameFull,
|
||||
topic,
|
||||
eventType,
|
||||
exception,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package space.nstart.pcp.routepassportconsumer.service
|
||||
|
||||
/**
|
||||
* Metadata входной Kafka record, из которой был получен route passport.
|
||||
*/
|
||||
data class RoutePassportSourceRecordMetadata(
|
||||
val topic: String,
|
||||
val partition: Int,
|
||||
val offset: Long,
|
||||
val key: String?,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
spring:
|
||||
application:
|
||||
name: pcp-route-processing-service
|
||||
profiles:
|
||||
default: local
|
||||
config:
|
||||
import: "configserver:"
|
||||
cloud:
|
||||
config:
|
||||
uri: ${CONFIG_SERVER_URI:http://192.168.100.160:38888}
|
||||
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
|
||||
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
|
||||
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: ${PCP_ROUTE_PROCESSING_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/pcp_route_processing}
|
||||
username: ${PCP_ROUTE_PROCESSING_DATASOURCE_USERNAME:postgres}
|
||||
password: ${PCP_ROUTE_PROCESSING_DATASOURCE_PASSWORD:password}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE route_passports (
|
||||
route_id UUID PRIMARY KEY,
|
||||
route_name_full TEXT NOT NULL,
|
||||
route_name_short VARCHAR(255) NOT NULL,
|
||||
ka_short VARCHAR(64) NOT NULL,
|
||||
ka_full VARCHAR(255) NOT NULL,
|
||||
orbit_number BIGINT NOT NULL,
|
||||
orbit_state VARCHAR(64) NOT NULL,
|
||||
interval_begin TIMESTAMP NOT NULL,
|
||||
interval_end TIMESTAMP NOT NULL,
|
||||
roll_angle_min NUMERIC NOT NULL,
|
||||
roll_angle_max NUMERIC NOT NULL,
|
||||
visir_angle_min NUMERIC NOT NULL,
|
||||
visir_angle_max NUMERIC NOT NULL,
|
||||
resolution_range NUMERIC NOT NULL,
|
||||
resolution_azimuth NUMERIC NOT NULL,
|
||||
polarisation JSONB NOT NULL,
|
||||
processing_level VARCHAR(64) NOT NULL,
|
||||
geometry_wkt TEXT NOT NULL,
|
||||
route_status VARCHAR(32) NOT NULL,
|
||||
trace_id VARCHAR(255),
|
||||
correlation_id VARCHAR(255),
|
||||
source_topic VARCHAR(255),
|
||||
source_partition INTEGER,
|
||||
source_offset BIGINT,
|
||||
source_key VARCHAR(255),
|
||||
raw_payload JSONB,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_route_passports_interval
|
||||
ON route_passports (interval_begin, interval_end);
|
||||
|
||||
CREATE INDEX idx_route_passports_ka_orbit
|
||||
ON route_passports (ka_short, orbit_number);
|
||||
|
||||
CREATE INDEX idx_route_passports_status
|
||||
ON route_passports (route_status);
|
||||
|
||||
CREATE UNIQUE INDEX ux_route_passports_source_record
|
||||
ON route_passports (source_topic, source_partition, source_offset)
|
||||
WHERE source_topic IS NOT NULL
|
||||
AND source_partition IS NOT NULL
|
||||
AND source_offset IS NOT NULL;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE route_passport_outbox (
|
||||
id UUID PRIMARY KEY,
|
||||
route_id UUID NOT NULL,
|
||||
event_type VARCHAR(128) NOT NULL,
|
||||
topic VARCHAR(255) NOT NULL,
|
||||
message_key VARCHAR(255) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
headers JSONB,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 10,
|
||||
next_attempt_at TIMESTAMP NOT NULL,
|
||||
locked_until TIMESTAMP,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
published_at TIMESTAMP,
|
||||
|
||||
CONSTRAINT fk_route_passport_outbox_route
|
||||
FOREIGN KEY (route_id)
|
||||
REFERENCES route_passports (route_id)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT ux_route_passport_outbox_route_event
|
||||
UNIQUE (route_id, event_type),
|
||||
|
||||
CONSTRAINT chk_route_passport_outbox_status
|
||||
CHECK (status IN ('PENDING', 'PUBLISHED', 'FAILED'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_route_passport_outbox_due
|
||||
ON route_passport_outbox (status, next_attempt_at, created_at);
|
||||
|
||||
CREATE INDEX idx_route_passport_outbox_locked_until
|
||||
ON route_passport_outbox (locked_until);
|
||||
|
||||
CREATE INDEX idx_route_passport_outbox_route_id
|
||||
ON route_passport_outbox (route_id);
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"id": "51b07d12-1823-4bd5-b55d-de02a5304c03",
|
||||
"traceid": "c0dd08ea-dd64-4633-8845-d1b163bdf506",
|
||||
"correlationid": "db605d5f-77e9-45b4-82cc-709837306257",
|
||||
"group": "stac_test",
|
||||
"source": "stac-writer-service",
|
||||
"type": "METADATA_NORMALIZED",
|
||||
"time": "2026-03-31T09:39:13.251462",
|
||||
"data": {
|
||||
"id": "S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE",
|
||||
"routeId": "51b07d12-1823-4bd5-b55d-de02a5304c03",
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[
|
||||
67.675827,
|
||||
38.114815
|
||||
],
|
||||
[
|
||||
64.779434,
|
||||
38.514816
|
||||
],
|
||||
[
|
||||
65.094017,
|
||||
40.015545
|
||||
],
|
||||
[
|
||||
68.05294,
|
||||
39.616795
|
||||
],
|
||||
[
|
||||
67.675827,
|
||||
38.114815
|
||||
]
|
||||
]
|
||||
]
|
||||
},
|
||||
"bbox": [
|
||||
64.779434,
|
||||
40.015545,
|
||||
68.05294,
|
||||
38.114815
|
||||
],
|
||||
"properties": {
|
||||
"datetime": "2026-03-31T12:08:30.570573Z",
|
||||
"created": null,
|
||||
"updated": null,
|
||||
"platform": "sentinel-1a",
|
||||
"instruments": null,
|
||||
"processing:level": "GRD",
|
||||
"route:orbit_number": "59796",
|
||||
"missionId": "S1A",
|
||||
"sat:orbit_state": "Descending",
|
||||
"polarisation": [
|
||||
"VH",
|
||||
"VV"
|
||||
],
|
||||
"rollAngle": [
|
||||
30.07864486915724,
|
||||
30.09378936608302
|
||||
],
|
||||
"visirAngle": [
|
||||
30.63331560676386,
|
||||
46.07086702096005
|
||||
],
|
||||
"intervalBegin": "2025-06-25T01:38:54.481190",
|
||||
"intervalEnd": "2025-06-25T01:39:19.479675",
|
||||
"resolutionRange": "1.000000e+01",
|
||||
"resolutionAzimuth": "1.000000e+01"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"rel": "self",
|
||||
"href": "http://app-opensearch:8082/collections/S1A-GRDH/items/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE",
|
||||
"type": "application/geo+json",
|
||||
"title": null
|
||||
},
|
||||
{
|
||||
"rel": "parent",
|
||||
"href": "http://app-opensearch:8082/collections/S1A-GRDH",
|
||||
"type": "application/json",
|
||||
"title": null
|
||||
},
|
||||
{
|
||||
"rel": "collection",
|
||||
"href": "http://app-opensearch:8082/collections/S1A-GRDH",
|
||||
"type": "application/json",
|
||||
"title": null
|
||||
},
|
||||
{
|
||||
"rel": "root",
|
||||
"href": "http://app-opensearch:8082",
|
||||
"type": "application/json",
|
||||
"title": null
|
||||
}
|
||||
],
|
||||
"assets": {
|
||||
"schema-calibration-vh": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939",
|
||||
"title": "xml",
|
||||
"description": null,
|
||||
"type": "application/xml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"schema-calibration-vv": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0",
|
||||
"title": "xml",
|
||||
"description": null,
|
||||
"type": "application/xml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"schema-noise-vh": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c",
|
||||
"title": "xml",
|
||||
"description": null,
|
||||
"type": "application/xml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"schema-noise-vv": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca",
|
||||
"title": "xml",
|
||||
"description": null,
|
||||
"type": "application/xml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"schema-product-vh": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f",
|
||||
"title": "xml",
|
||||
"description": null,
|
||||
"type": "application/xml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"schema-product-vv": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee",
|
||||
"title": "xml",
|
||||
"description": null,
|
||||
"type": "application/xml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"safe-manifest": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53",
|
||||
"title": "safe",
|
||||
"description": null,
|
||||
"type": "application/xml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"vh": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648",
|
||||
"title": "tif",
|
||||
"description": null,
|
||||
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
|
||||
"roles": [
|
||||
"visual"
|
||||
]
|
||||
},
|
||||
"vv": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb",
|
||||
"title": "tif",
|
||||
"description": null,
|
||||
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
|
||||
"roles": [
|
||||
"visual"
|
||||
]
|
||||
},
|
||||
"kml": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54",
|
||||
"title": "kml",
|
||||
"description": null,
|
||||
"type": "application/kml",
|
||||
"roles": [
|
||||
"metadata"
|
||||
]
|
||||
},
|
||||
"thumbnail": {
|
||||
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0",
|
||||
"title": "png",
|
||||
"description": null,
|
||||
"type": "image/png",
|
||||
"roles": [
|
||||
"thumbnail",
|
||||
"overview"
|
||||
]
|
||||
}
|
||||
},
|
||||
"collection": "S1A-GRDH",
|
||||
"stac_version": "1.0.0",
|
||||
"stac_extensions": null
|
||||
},
|
||||
"datacontenttype": "application/json",
|
||||
"specversion": "1.0"
|
||||
}
|
||||
Reference in New Issue
Block a user