From b37009a1699214b5813930f9e7f2d543b482f0d5 Mon Sep 17 00:00:00 2001 From: "Ivan I. Ovchinnikov" Date: Tue, 7 Jul 2026 22:17:44 +0300 Subject: [PATCH] stations readable from MDM (raw). get by id ok --- config-repo/pcp-stations-service.yaml | 3 + deploy/local-bundle/docker-compose.yml | 2 +- deploy/local-jar/run-services.sh | 2 +- .../pcp-stations-service/build.gradle.kts | 7 +- .../configuration/WebClientConfig.kt | 12 ++ .../integration/NsiStationClient.kt | 103 ++++++++++++++++++ .../service/StationService.kt | 18 +-- .../controller/StationControllerTest.kt | 6 + 8 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/WebClientConfig.kt create mode 100644 services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/integration/NsiStationClient.kt diff --git a/config-repo/pcp-stations-service.yaml b/config-repo/pcp-stations-service.yaml index e3184ca..ff70a10 100644 --- a/config-repo/pcp-stations-service.yaml +++ b/config-repo/pcp-stations-service.yaml @@ -88,3 +88,6 @@ 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} diff --git a/deploy/local-bundle/docker-compose.yml b/deploy/local-bundle/docker-compose.yml index 8a5437d..b67e421 100644 --- a/deploy/local-bundle/docker-compose.yml +++ b/deploy/local-bundle/docker-compose.yml @@ -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 diff --git a/deploy/local-jar/run-services.sh b/deploy/local-jar/run-services.sh index d602f5f..5b0640f 100755 --- a/deploy/local-jar/run-services.sh +++ b/deploy/local-jar/run-services.sh @@ -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 diff --git a/services/pcp-stations-service/build.gradle.kts b/services/pcp-stations-service/build.gradle.kts index 2b03a06..1cb91f9 100644 --- a/services/pcp-stations-service/build.gradle.kts +++ b/services/pcp-stations-service/build.gradle.kts @@ -50,6 +50,11 @@ dependencies { implementation("org.locationtech.jts:jts-core:1.19.0") + // in‑memory cache с поддержкой TTL, позволяет задать TTL и автоматически удалять устаревшие записи. + implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") + + // обёртка для «circuit breaker», «retry» и т.п. защищает от падения при ошибках сети (circuit‑breaker + 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 { diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/WebClientConfig.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/WebClientConfig.kt new file mode 100644 index 0000000..dfe69ed --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/WebClientConfig.kt @@ -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() +} \ No newline at end of file diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/integration/NsiStationClient.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/integration/NsiStationClient.kt new file mode 100644 index 0000000..313ae7e --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/integration/NsiStationClient.kt @@ -0,0 +1,103 @@ +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.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientException +import org.springframework.web.reactive.function.client.WebClientResponseException +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, + + @Value("\${classifier.stations-url:https://ordinis.k8s.265.nstart.cloud/api/v1/ground_station/records}") + private val stationsUrl: String +) { + private val log = LoggerFactory.getLogger(javaClass) + + private val webClient: WebClient = webClientBuilder.build() + + fun fetchStations(): List { + val records = try { + webClient.get() + .uri(stationsUrl) + .retrieve() + .bodyToMono(Array::class.java) + .block(Duration.ofSeconds(15)) + ?.toList() + ?: emptyList() + } catch (ex: WebClientResponseException) { + log.error( + "NSI stations request failed. url={}, status={}, body={}", + stationsUrl, + ex.statusCode, + ex.responseBodyAsString, + ex + ) + throw NsiStationsUnavailableException("NSI stations request failed: $stationsUrl", ex) + } catch (ex: WebClientException) { + log.error("NSI stations request failed. url={}", stationsUrl, ex) + throw NsiStationsUnavailableException("NSI stations request failed: $stationsUrl", ex) + } + + log.info("Loaded {} station records from NSI. url={}", records.size, stationsUrl) + + return records.mapIndexed { index, record -> + record.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() + } +} + +class NsiStationsUnavailableException( + message: String, + cause: Throwable +) : RuntimeException(message, 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 +) diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/service/StationService.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/service/StationService.kt index 13bb8cf..6f7a01d 100644 --- a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/service/StationService.kt +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/service/StationService.kt @@ -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 = 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) } diff --git a/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationControllerTest.kt b/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationControllerTest.kt index caa52b5..7b23777 100644 --- a/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationControllerTest.kt +++ b/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationControllerTest.kt @@ -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()