2 Commits

Author SHA1 Message Date
Ivan I. Ovchinnikov 10a080bd8c main request, mdm properties set 2026-07-14 20:11:52 +03:00
Ivan I. Ovchinnikov b37009a169 stations readable from MDM (raw). get by id ok 2026-07-07 22:17:44 +03:00
10 changed files with 221 additions and 11 deletions
+11
View File
@@ -88,3 +88,14 @@ spring:
max-in-memory-size: 20MB
server:
port: ${pcp.ports.stations:8080}
classifier:
stations-url: ${CLASSIFIER_STATIONS_URL:https://ordinis.k8s.265.nstart.cloud/api/v1/ground_station/records}
# cache TTL
stations-cache-ttl: ${CLASSIFIER_STATIONS_CACHE_TTL:15m}
# MDM retries
stations-refresh-attempts: ${CLASSIFIER_STATIONS_REFRESH_ATTEMPTS:3}
# MDM retry delay
stations-refresh-retry-delay: ${CLASSIFIER_STATIONS_REFRESH_RETRY_DELAY:300ms}
# MDM failure cooldown
stations-refresh-failure-cooldown: ${CLASSIFIER_STATIONS_REFRESH_FAILURE_COOLDOWN:5s}
+1 -1
View File
@@ -64,7 +64,7 @@ services:
volumes:
- kafka-data:/var/lib/kafka/data
healthcheck:
test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server kafka:9092 --list >/dev/null 2>&1"]
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --list >/dev/null 2>&1"]
interval: 10s
timeout: 10s
retries: 30
+1 -1
View File
@@ -192,7 +192,7 @@ wait_for_postgres() {
wait_for_kafka() {
local attempt
for attempt in {1..60}; do
if compose exec -T kafka kafka-topics.sh --bootstrap-server kafka:9092 --list >/dev/null 2>&1; then
if compose exec -T kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --list >/dev/null 2>&1; then
echo "Kafka is ready."
return 0
fi
@@ -50,6 +50,11 @@ dependencies {
implementation("org.locationtech.jts:jts-core:1.19.0")
// inmemory cache с поддержкой TTL, позволяет задать TTL и автоматически удалять устаревшие записи.
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
// обёртка для «circuit breaker», «retry» и т.п. защищает от падения при ошибках сети (circuitbreaker + fallback).
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
testImplementation("junit:junit")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
@@ -65,7 +70,7 @@ dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}")
}
}
}
tasks.withType<Jar> {
@@ -1,9 +1,11 @@
package space.nstart.pcp.pcp_stations_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@ConfigurationPropertiesScan
class PcpStationsServiceApplication
fun main(args: Array<String>) {
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_stations_service.configuration
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
@ConfigurationProperties(prefix = "classifier")
data class ClassifierProperties(
val stationsUrl: String = "https://ordinis.k8s.265.nstart.cloud/api/v1/ground_station/records",
val stationsCacheTtl: Duration = Duration.ofMinutes(15),
val stationsRefreshAttempts: Int = 3,
val stationsRefreshRetryDelay: Duration = Duration.ofMillis(300),
val stationsRefreshFailureCooldown: Duration = Duration.ofSeconds(5)
)
@@ -0,0 +1,12 @@
package space.nstart.pcp.pcp_stations_service.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.client.WebClient
@Configuration
class WebClientConfig {
@Bean
fun webClientBuilder(): WebClient.Builder = WebClient.builder()
}
@@ -0,0 +1,159 @@
package space.nstart.pcp.pcp_stations_service.integration
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientRequestException
import org.springframework.web.reactive.function.client.WebClientResponseException
import space.nstart.pcp.pcp_stations_service.configuration.ClassifierProperties
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import java.time.Duration
import java.util.UUID
@Component
class NsiStationClient(
webClientBuilder: WebClient.Builder,
private val classifierProperties: ClassifierProperties,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val webClient: WebClient = webClientBuilder.build()
private fun classifyResponseException(
ex: WebClientResponseException
): NsiStationsException {
val statusCode = ex.statusCode.value()
return if (
statusCode == 408 ||
statusCode == 429 ||
statusCode in 500..599
) {
NsiStationsUnavailableException(
classifierUrl = classifierProperties.stationsUrl,
statusCode = statusCode,
cause = ex
)
} else {
NsiStationsResponseException(
classifierUrl = classifierProperties.stationsUrl,
statusCode = statusCode,
cause = ex
)
}
}
fun fetchStations(): List<StationDTO> {
log.info(
"Fetching stations from NSI. url={}",
classifierProperties.stationsUrl
)
val response: List<NsiStationRecord> = try {
webClient.get()
.uri(classifierProperties.stationsUrl)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(Array<NsiStationRecord>::class.java)
.block(Duration.ofSeconds(10))
?.toList()
?: throw NsiStationsUnavailableException(
classifierUrl = classifierProperties.stationsUrl,
statusCode = null,
cause = IllegalStateException("NSI response body is absent")
)
} catch (ex: NsiStationsException) {
throw ex
} catch (ex: WebClientResponseException) {
throw classifyResponseException(ex)
} catch (ex: WebClientRequestException) {
throw NsiStationsUnavailableException(
classifierUrl = classifierProperties.stationsUrl,
statusCode = null,
cause = ex
)
} catch (ex: IllegalStateException) {
/*
* Сюда, в частности, попадает timeout от block(Duration).
*/
throw NsiStationsUnavailableException(
classifierUrl = classifierProperties.stationsUrl,
statusCode = null,
cause = ex
)
}
return response.mapIndexed { index, station ->
station.toStationDTO(number = index + 1)
}
}
private fun NsiStationRecord.toStationDTO(number: Int): StationDTO {
return StationDTO(
id = id.toUuidOrNull(),
number = number,
name = data?.name ?: businessKey ?: "",
position = PositionDTO(
lat = data?.lat ?: 0.0,
long = data?.lon ?: 0.0,
height = data?.elevationM ?: 0.0
),
elevationMin = data?.elevationMinDeg ?: 0.0,
elevationMax = 90.0
)
}
private fun String?.toUuidOrNull(): UUID? =
this?.let {
runCatching { UUID.fromString(it) }.getOrNull()
}
}
sealed class NsiStationsException(
message: String,
cause: Throwable?
) : RuntimeException(message, cause)
class NsiStationsUnavailableException(
val classifierUrl: String,
val statusCode: Int?,
cause: Throwable?
) : NsiStationsException(
message = "NSI stations classifier is temporarily unavailable: $classifierUrl",
cause = cause
)
class NsiStationsResponseException(
val classifierUrl: String,
val statusCode: Int,
cause: Throwable?
) : NsiStationsException(
message = "NSI stations classifier returned HTTP $statusCode: $classifierUrl",
cause = cause
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class NsiStationRecord(
val id: String? = null,
val businessKey: String? = null,
val data: NsiStationData? = null
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class NsiStationData(
val lat: Double? = null,
val lon: Double? = null,
val code: String? = null,
val name: String? = null,
val status: String? = null,
@JsonProperty("elevation_m")
val elevationM: Double? = null,
@JsonProperty("elevation_min_deg")
val elevationMinDeg: Double? = null
)
@@ -4,27 +4,29 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_stations_service.configuration.CustomErrorException
import space.nstart.pcp.pcp_stations_service.entity.StationEntity
import space.nstart.pcp.pcp_stations_service.integration.NsiStationClient
import space.nstart.pcp.pcp_stations_service.repository.StationRepository
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import java.util.UUID
@Service
class StationService {
class StationService (
){
@Autowired
private lateinit var stationRepository: StationRepository
@Autowired
private lateinit var nsiStationClient: NsiStationClient
fun all(): List<StationDTO> = nsiStationClient.fetchStations()
fun all() = stationRepository.findAll().map { it.toDTO() }
fun byId(id: UUID): StationDTO =
nsiStationClient.fetchStations()
.firstOrNull { it.id == id }
?: throw CustomErrorException("Станция $id не найдена")
fun add(station : StationDTO): StationDTO = stationRepository.save(StationEntity(station)).toDTO()
fun byId(id : UUID): StationDTO =
stationRepository.findByStationId(id)
.map { it.toDTO() }
.orElseThrow { CustomErrorException("Станция $id не найдена") }
fun delete(id : UUID): Int = stationRepository.deleteByStationId(id)
}
@@ -51,6 +51,12 @@ class StationControllerTest {
verify(stationService).add(anyStation())
}
@Test
fun `get all stations`() {
val stations = stationService.getStations();
assertEquals(8, stations.size)
}
@Test
fun `station get by id endpoint returns dto without optional wrapper`() {
val station = sampleStation()