This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,8 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
@@ -0,0 +1,103 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
jacoco
// id("org.graalvm.buildtools.native")
}
version = "1.0.0"
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
dependencies {
implementation(project(":libs:pcp-types-lib"))
implementation(project(":libs:ballistics-lib"))
implementation("${property("dep.spring.actuator")}")
implementation("org.springframework.boot:spring-boot-starter-logging")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("jakarta.validation:jakarta.validation-api")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-webflux")
// implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}")
implementation("org.springframework.kafka:spring-kafka")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
implementation("jakarta.validation:jakarta.validation-api")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
testImplementation("junit:junit")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}")
}
}
tasks.withType<Jar> {
manifest {
attributes["Built-By"] = "nstart"
attributes["Implementation-Version"] = archiveVersion
}
}
tasks.withType<Test> {
enabled = true
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
systemProperty("spring.profiles.active", "test")
}
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
html.required = true
csv.required = false
}
}
sonar {
properties {
property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}")
}
}
@@ -0,0 +1,15 @@
package space.nstart.pcp.tle_monitoring_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
class TleMonitoringServiceApplication
fun main(args: Array<String>) {
runApplication<TleMonitoringServiceApplication>(*args)
}
@@ -0,0 +1,31 @@
package space.nstart.pcp.tle_monitoring_service.configuration
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.support.WebExchangeBindException
import reactor.core.publisher.Mono
import kotlin.collections.forEach
import kotlin.collections.set
import kotlin.to
class CustomValidationException(message: String) : RuntimeException(message)
class CustomErrorException(message: String) : RuntimeException(message)
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(CustomValidationException::class)
fun handleValidation(ex: CustomValidationException): ResponseEntity<Map<String, String>> {
return ResponseEntity.badRequest()
.body(mapOf("error" to ex.message!!))
}
@ExceptionHandler(CustomErrorException::class)
fun handleError(ex: CustomErrorException): ResponseEntity<Map<String, String>> {
return ResponseEntity.internalServerError()
.body(mapOf("error" to ex.message!!))
}
}
@@ -0,0 +1,135 @@
package space.nstart.pcp.tle_monitoring_service.configuration
import org.apache.kafka.clients.admin.AdminClientConfig
import org.apache.kafka.clients.admin.NewTopic
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.header.Header
import org.apache.kafka.common.serialization.StringDeserializer
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.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.KafkaAdmin
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
import org.springframework.kafka.listener.adapter.RecordFilterStrategy
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import java.nio.charset.StandardCharsets
import kotlin.String
@ConditionalOnProperty(name=["spring.kafka.bootstrap-servers"], matchIfMissing = false)
@Configuration
class KafkaConfig {
companion object {
const val HEADER_NAME: String = "type"
const val TARGET_CONSUMER_HEADER_NAME: String = "target_consumer"
}
@Value("\${spring.kafka.template.default-topic}")
private lateinit var defaultTopic: String
@Bean
fun tleTopic(): NewTopic =
NewTopic(defaultTopic, 1, 1)
@Value("\${spring.kafka.bootstrap-servers}")
private lateinit var addr: String
@Value("\${spring.kafka.consumer.group-id}")
private lateinit var groupId: String
@Bean
fun template(): KafkaTemplate<String, Any> {
return KafkaTemplate(producerFactory())
}
@Bean
fun kafkaAdmin(): KafkaAdmin {
val configs = hashMapOf<String, Any>()
configs[AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG] = addr
return KafkaAdmin(configs)
}
@Bean
fun consumerFactory(): ConsumerFactory<String, Any> {
val props = hashMapOf<String, Any>()
props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr
props[ConsumerConfig.GROUP_ID_CONFIG] = groupId
props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = "latest"
props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false
props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java
props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java
return DefaultKafkaConsumerFactory(props)
}
@Bean
fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory<String, Any> =
ConcurrentKafkaListenerContainerFactory<String, Any>()
.apply { setConsumerFactory(consumerFactory()) }
@Bean
fun producerFactory(): ProducerFactory<String, Any> {
val configProps = hashMapOf<String, Any>()
configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr
configProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java
configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java
return DefaultKafkaProducerFactory(configProps)
}
private fun getRecordFilterStrategy(event: PcpKafkaEvent): RecordFilterStrategy<String, String> {
return RecordFilterStrategy { consumerRecord: ConsumerRecord<String, String> ->
val eventTypeHeader: Header? = consumerRecord.headers().lastHeader(HEADER_NAME)
if (eventTypeHeader != null) {
val headerValue = String(eventTypeHeader.value(), StandardCharsets.UTF_8)
return@RecordFilterStrategy event.name != headerValue
}
true
}
}
private fun getRecordFilterStrategyByConsumer(): RecordFilterStrategy<String, String> {
return RecordFilterStrategy { consumerRecord: ConsumerRecord<String, String> ->
val consumerGroupHeader: Header? = consumerRecord.headers().lastHeader(TARGET_CONSUMER_HEADER_NAME)
if (consumerGroupHeader != null) {
val headerValue = String(consumerGroupHeader.value(), StandardCharsets.UTF_8)
return@RecordFilterStrategy "respondedListener" != headerValue
}
true
}
}
@Bean("placedIcFilter")
fun placedFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategy(PcpKafkaEvent.ICPlacedEvent)
}
@Bean("updatedIcFilter")
fun updatedIcFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategy(PcpKafkaEvent.ICUpdatedEvent)
}
@Bean("satelliteDeletedFilter")
fun satelliteDeletedFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategy(PcpKafkaEvent.SatelliteDeletedEvent)
}
@Bean("respondedFilter")
fun respondedFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategyByConsumer()
}
}
@@ -0,0 +1,9 @@
package space.nstart.pcp.tle_monitoring_service.configuration
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "tle.polling")
data class TlePollingProperties(
var enabled: Boolean = false,
var timeoutSeconds: Long = 10_800,
)
@@ -0,0 +1,27 @@
package space.nstart.pcp.tle_monitoring_service.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.time.Duration
@Configuration
class WebClientConfig {
@Bean
fun webClientBuilder(): WebClient.Builder {
val httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(10))
return WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.codecs { configurer ->
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)
}
}
@Bean
fun webClient(webClientBuilder: WebClient.Builder): WebClient = webClientBuilder.build()
}
@@ -0,0 +1,54 @@
package space.nstart.pcp.tle_monitoring_service.controller
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO
import space.nstart.pcp.tle_monitoring_service.repository.TLERepository
import space.nstart.pcp.tle_monitoring_service.service.CelesTrakService
@RestController
@RequestMapping("v1/api/tle")
class TLEController {
@Autowired
private lateinit var tleRepository: TLERepository
@Autowired
private lateinit var celestrakService: CelesTrakService
@GetMapping()
fun all() = tleRepository.findAll().map { tle ->
val t = tle.tle.split("\r\n")
TLEExtensionDTO(
tle.satelliteId,
tle.revolution,
TLEDTO(t[0], t[1], t[2])
)
}
// @GetMapping("/test")
// fun update() = celestrakService.updateDB()
@PostMapping("load-array")
fun arra(@RequestBody data : String, @RequestParam name : String) =
celestrakService.loadArray(data, name)
@PostMapping ("check-cycle")
fun checkCycle(@RequestParam id : Long) =
celestrakService.checkCycle(id)
@PostMapping("from-celestrack")
fun tleFromSource(@RequestParam noradIn : Long, @RequestParam("message") sendMessage : Boolean) =
celestrakService.getTleByNoradIdRaw(noradIn, false, sendMessage)
}
@@ -0,0 +1,23 @@
package space.nstart.pcp.tle_monitoring_service.controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.tle_monitoring_service.service.TlePollingSettingsService
import space.nstart.pcp.tle_monitoring_service.service.UpdateTlePollingSettingsRequest
@RestController
@RequestMapping("v1/api/tle/polling-settings")
class TlePollingSettingsController(
private val pollingSettingsService: TlePollingSettingsService,
) {
@GetMapping
fun current() = pollingSettingsService.current()
@PutMapping
fun update(@RequestBody request: UpdateTlePollingSettingsRequest) =
pollingSettingsService.update(request)
}
@@ -0,0 +1,27 @@
package space.nstart.pcp.tle_monitoring_service.entity
import jakarta.persistence.*
import java.time.LocalDateTime
import java.time.ZoneOffset
@Entity
@Table(name = "tle")
class TLEEntity (
@Id
@Column(name = "tle_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id : Long? = null,
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(nullable = false)
val revolution : Long = 0,
@Column(nullable = false)
val tle : String = "",
var timeTle : LocalDateTime? = null,
@Column(name = "time_rec", nullable = false)
val timeRec : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
)
@@ -0,0 +1,75 @@
package space.nstart.pcp.tle_monitoring_service.message
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.tle_monitoring_service.configuration.KafkaConfig
import tools.jackson.core.JacksonException
import tools.jackson.databind.ObjectMapper
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture
import java.util.function.BiConsumer
import kotlin.Any
import kotlin.String
@ConditionalOnProperty(name=["spring.kafka.bootstrap-servers"], matchIfMissing = false)
@Component
class KafkaProducer {
@Value("\${spring.kafka.template.default-topic}")
private val defaultTopic: String? = null
@Value("\${spring.application.name:opkat-ks-request-service}")
private val applicationName: String = ""
@Autowired
private lateinit var kafkaTemplate: KafkaTemplate<String, Any>
@Autowired
private lateinit var objectMapper: ObjectMapper
private val log: Logger = LoggerFactory.getLogger(this::class.java)
fun send(message: KafkaMessage<Any>, topic: String? = null, extraHeaders: Map<String, Any>? = null) {
try {
message.source = applicationName
val jsonMessage = objectMapper.writeValueAsString(message)
val selectedTopic = topic ?: defaultTopic
val record: ProducerRecord<String, Any> = ProducerRecord(
selectedTopic,
message.traceId,
jsonMessage
)
record.headers().add(KafkaConfig.HEADER_NAME, message.type.enumCast()?.name?.toByteArray(StandardCharsets.UTF_8))
extraHeaders?.forEach { record.headers().add(it.key, it.value.toString().toByteArray(StandardCharsets.UTF_8)) }
val future: CompletableFuture<SendResult<String, Any>> = kafkaTemplate.send(record)
future.whenCompleteAsync { result: SendResult<String, Any>, ex: Throwable? ->
if (ex == null) log.debug(
"В Kafka отправлено сообщение. Topic: {}. Header: {}. Key: {}. Value: {}.",
result.producerRecord.topic(),
result.producerRecord.headers().lastHeader(KafkaConfig.HEADER_NAME),
result.producerRecord.key(),
result.producerRecord.value()
)
else log.error(
"Ошибка отправки сообщения в Kafka. Topic: {}. Header: {}. Key: {}. Value: {}.",
selectedTopic,
message.type.enumCast()?.name,
message.traceId,
jsonMessage
)
}
} catch (e: JacksonException) {
throw RuntimeException("Ошибка конвертации сообщения в json: " + e.message, e)
}
}
}
@@ -0,0 +1,72 @@
package space.nstart.pcp.tle_monitoring_service.message
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.tle_monitoring_service.service.SatelliteDeletedService
private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-tle-monitoring-service-satellite-deleted"
@Component
class SatelliteDeletedKafkaListener(
objectMapperProvider: ObjectProvider<ObjectMapper>,
private val satelliteDeletedService: SatelliteDeletedService
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
@KafkaListener(
id = "satelliteDeletedFilter",
topics = ["\${app.kafka.topics.satellites:pcp.satellites}"],
groupId = SATELLITE_DELETED_CONSUMER_GROUP,
filter = "satelliteDeletedFilter"
)
fun consume(message: String?) {
if (message.isNullOrBlank()) {
logger.warn("Received empty SatelliteDeletedEvent Kafka message")
return
}
try {
val kafkaMessage = objectMapper.readValue(
message,
object : TypeReference<KafkaMessage<SatelliteDeletedEventDTO>>() {}
)
val event = kafkaMessage.data
logger.info(
"Received SatelliteDeletedEvent in tle-monitoring-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}",
event.satelliteId,
event.noradId,
kafkaMessage.id,
kafkaMessage.traceId,
kafkaMessage.source,
SATELLITE_DELETED_CONSUMER_GROUP
)
event.deleteIdentifiers().forEach { satelliteId ->
satelliteDeletedService.deleteSatelliteData(satelliteId)
}
logger.info(
"SatelliteDeletedEvent processed in tle-monitoring-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}",
event.satelliteId,
event.noradId,
kafkaMessage.id,
kafkaMessage.traceId,
kafkaMessage.source,
SATELLITE_DELETED_CONSUMER_GROUP
)
} catch (exception: Exception) {
logger.error("Failed to process SatelliteDeletedEvent in tle-monitoring-service", exception)
throw exception
}
}
private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List<Long> =
listOfNotNull(satelliteId, noradId).distinct()
}
@@ -0,0 +1,15 @@
package space.nstart.pcp.tle_monitoring_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.tle_monitoring_service.entity.TLEEntity
interface TLERepository : JpaRepository<TLEEntity, Long> {
fun findBySatelliteIdAndRevolution(id : Long, rev : Long) : List<TLEEntity>
fun findAllBySatelliteId(id : Long) : List<TLEEntity>
fun countBySatelliteId(id: Long): Long
fun deleteAllBySatelliteId(id: Long): Long
}
@@ -0,0 +1,330 @@
package space.nstart.pcp.tle_monitoring_service.service
import ballistics.Ballistics
import ballistics.types.BallisticsError
import ballistics.types.TLE
import ballistics.utils.toDateTime
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.reactive.function.client.bodyToMono
import org.springframework.web.util.UriComponentsBuilder
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import space.nstart.pcp.tle_monitoring_service.configuration.CustomErrorException
import space.nstart.pcp.tle_monitoring_service.entity.TLEEntity
import space.nstart.pcp.tle_monitoring_service.message.KafkaProducer
import space.nstart.pcp.tle_monitoring_service.repository.TLERepository
import java.time.Duration
import java.time.LocalDateTime
import java.util.UUID
import kotlin.math.PI
import kotlin.math.abs
import kotlin.text.split
@Service
class CelesTrakService(
private val webClient: WebClient,
private val pollingSettingsService: TlePollingSettingsService,
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val objectMapper = jacksonObjectMapper()
@Autowired
private lateinit var sender: KafkaProducer
@Autowired
private lateinit var tleRepository: TLERepository
@Autowired
private lateinit var complexMissionService: ComplexMissionService
companion object {
private const val BASE_URL = "https://celestrak.org"
private const val USER_AGENT = "SatelliteTracker/1.0 (Spring Boot)"
}
@Scheduled(fixedDelay = 1000)
fun findExpired(): Flux<TLEDTO> {
if (!pollingSettingsService.shouldStartPolling()) {
return Flux.empty()
}
val settings = pollingSettingsService.current()
pollingSettingsService.markPollingStarted()
logger.info("Запуск цикла опроса TLE. timeoutSeconds=${settings.timeoutSeconds}")
return complexMissionService.getSatellites().filter { s -> s.scanTLE }.flatMap {
sat ->
getTleByNoradIdRaw(sat.noradId, send = true, sendForce = false)
}
}
fun updateDB(){
val bal = Ballistics()
tleRepository.findAll().forEach { tle ->
try{
val dat = tle.tle.split("\r\n")
val params = bal.getTLEParams(TLE(dat[1], dat[2]))
tleRepository.save(tle.apply {
timeTle = params.time
})
} catch (ex : Exception){
logger.warn("Ошибка разбора TLE ${ex.message}")
}
}
}
fun loadArray(data : String, name : String){
val tles = data.split("\n")
var first = true
var s1 : String = ""
var s2 : String
for (s in tles){
if (first){
s1 = s
} else {
s2 = s
val id = getNoradIdFromTLE(s1)
mkTle(TLEDTO(name,s1, s2), id, false, false)
}
first = ! first
}
}
fun getTleArrayByNoradIdRaw(noradId: Long, name: String): Mono<Void> {
// Используем UriComponentsBuilder для корректного построения URL
val uri = UriComponentsBuilder.fromUriString("https://www.space-track.org/basicspacedata/query/class/gp_history/norad_cat_id/$noradId/CREATION_DATE/%3Enow-60/format/tle")
.build()
.toUri()
logger.info("Запрос архива TLE для NORAD ID: $noradId, URI: $uri")
return webClient.get()
.uri(uri)
.retrieve()
.bodyToMono<String>()
.timeout(Duration.ofSeconds(15))
.doOnNext { rawText ->
loadArray(rawText, name)
}
.doOnError { error ->
handleError(error, noradId, "raw-array")
}
.then()
}
/**
* Получение TLE в сыром текстовом формате
*/
fun getTleByNoradIdRaw(noradId: Long, send : Boolean, sendForce : Boolean): Mono<TLEDTO> {
// Используем UriComponentsBuilder для корректного построения URL
val uri = UriComponentsBuilder.fromUriString("$BASE_URL/NORAD/elements/gp.php")
.queryParam("CATNR", noradId)
.queryParam("FORMAT", "tle")
.build()
.toUri()
logger.info("Запрос raw TLE для NORAD ID: $noradId, URI: $uri")
return webClient.get()
.uri(uri)
.header("User-Agent", USER_AGENT)
.retrieve()
.bodyToMono<String>()
.timeout(Duration.ofSeconds(15))
.map { rawText ->
parseRawTle(rawText, noradId)
}
.onErrorResume { error ->
handleError(error, noradId, "raw")
Mono.just(TLEDTO())
}
.doOnSuccess { tle ->
if (tle != null) {
mkTle(tle, noradId, send, sendForce)
}
}
}
fun mkTle(tle : TLEDTO, noradId : Long, send : Boolean, sendForce : Boolean){
val rev = getRevolutionNumberFromTLE(tle.second)
if (rev <= 0)
logger.error("Не удалось прочитать номер витка из строки ${tle.second} для id $noradId")
else {
if (tleRepository.findBySatelliteIdAndRevolution(noradId, rev).isEmpty()) {
val bal = Ballistics()
try {
val params = bal.getTLEParams(TLE(tle.first, tle.second))
tleRepository.save(
TLEEntity(
null,
noradId,
rev,
"${tle.header ?: "---"}\r\n${tle.first}\r\n${tle.second}",
timeTle = params.time
)
)
logger.info("Данные TLE для noradId $noradId сохранены в БД")
} catch (ex: Exception) {
logger.warn("Ошибка разбора TLE ${ex.message}")
throw CustomErrorException("Ошибка разбора TLE ${ex.message}")
}
if (send && !sendForce) {
sender.send(
KafkaMessage(
type = PcpKafkaEvent.ICUpdatedEvent,
data = tle,
traceId = UUID.randomUUID().toString()
)
)
logger.info("Данные TLE для noradId $noradId отправлены в очередь сообщений")
}
} else
logger.info("Данные TLE для noradId $noradId не изменились")
if (sendForce) {
logger.info("Данные TLE для noradId $noradId принудительно отправлены в очередь сообщений")
sender.send(
KafkaMessage(
type = PcpKafkaEvent.ICUpdatedEvent,
data = tle,
traceId = UUID.randomUUID().toString()
)
)
}
}
}
fun getRevolutionNumberFromTLE(line2: String): Long {
// Номер витка находится в позициях 64-68 (индексы 63-67)
if (line2.length >= 68) {
val revNumberStr = line2.substring(63, 68).trim()
return revNumberStr.toLongOrNull() ?: 0
}
return 0
}
fun getNoradIdFromTLE(line1: String): Long {
// Номер витка находится в позициях 64-68 (индексы 63-67)
if (line1.length >= 15) {
val noradId = line1.substring(2, 7).trim()
return noradId.toLongOrNull() ?: 0
}
return 0
}
/**
* Парсинг сырого TLE текста
*/
private fun parseRawTle(rawText: String, noradId: Long): TLEDTO {
val lines = rawText.lines()
.map { it.trim() }
.filter { it.isNotBlank() }
return when {
lines.size >= 3 -> {
TLEDTO(
lines[0],
lines[1],
lines[2]
)
}
lines.size == 2 -> {
// Если нет имени объекта
TLEDTO(
"NORAD-$noradId",
lines[0],
lines[1]
)
}
else -> {
logger.warn("Некорректный TLE формат для NORAD ID: $noradId")
throw CustomErrorException("Не удалось получить TLE: $lines")
}
}
}
private fun handleError(error: Throwable, noradId: Long, format: String) {
when (error) {
is WebClientResponseException -> {
logger.error("HTTP ошибка при запросе $format TLE для NORAD ID: $noradId. " +
"Статус: ${error.statusCode}, Тело: ${error.responseBodyAsString}", error)
throw CustomErrorException("HTTP ошибка при запросе $format TLE для NORAD ID: $noradId. " +
"Статус: ${error.statusCode}, Тело: ${error.responseBodyAsString}")
}
is java.net.UnknownHostException -> {
logger.error("Не удалось разрешить хост для запроса TLE (NORAD ID: $noradId). " +
"Проверьте подключение к интернету.", error)
throw CustomErrorException("Не удалось разрешить хост для запроса TLE (NORAD ID: $noradId). " +
"Проверьте подключение к интернету.")
}
is java.net.SocketTimeoutException -> {
logger.error("Таймаут при запросе TLE для NORAD ID: $noradId", error)
throw CustomErrorException("Таймаут при запросе TLE для NORAD ID: $noradId")
}
else -> {
logger.error("Ошибка при запросе $format TLE для NORAD ID: $noradId", error)
throw CustomErrorException("Ошибка при запросе $format TLE для NORAD ID: $noradId")
}
}
}
// https://www.space-track.org/basicspacedata/query/class/gp_history/norad_cat_id/39634/CREATION_DATE/%3Enow-60/format/tle
fun checkCycle(id : Long){
val bal = Ballistics()
val tles = tleRepository.findAllBySatelliteId(id)
.sortedBy { tle -> tle.timeTle }
var first = true
var vit = 0
var tVuz = LocalDateTime.now()
var lVuz = 0.0
for(t in tles){
var s = t.tle.split("\r\n")
val r = bal.calculateOrbPoints(TLE(s[1],s[2]), 86400.0 * 10)
if (r != BallisticsError.OK)
throw CustomErrorException("Ошибка расчета прогноза")
if (first){
first = false
vit = bal.revolutions.first().vuz.vit
lVuz = bal.revolutions.first().lVuz * 180.0 / PI
tVuz = toDateTime(bal.revolutions.first().vuz.t)
} else {
for (v in bal.revolutions){
val dL = abs(v.lVuz* 180.0 / PI - lVuz)
val dvit = v.vuz.vit-vit
if (dL < 0.005){
val dt : Long = Duration.between(tVuz, toDateTime(v.vuz.t)).seconds
logger.info("${tVuz} - ${toDateTime(v.vuz.t)} сдвиг витков ${abs(vit - v.vuz.vit)} : ${dL * PI / 180.0} : ${v.vuz.r.z} : $dt : ${dt/86400}")
}
}
}
}
}
}
@@ -0,0 +1,38 @@
package space.nstart.pcp.tle_monitoring_service.service
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
@Service
class ComplexMissionService
(private val webClientBuilder: WebClient.Builder) {
@Value("\${settings.satellite-catalog-service:\${settings.complex-mission-service:pcp-satellite-catalog-service}}")
val satelliteCatalogServiceURI = "pcp-satellite-catalog-service"
fun getSatellites(): Flux<SatelliteInfoDTO> =
webClientBuilder.build()
.get()
.uri("$satelliteCatalogServiceURI/api/satellites")
.retrieve()
.bodyToFlux(SatelliteSummaryDTO::class.java)
.map { satellite ->
SatelliteInfoDTO(
noradId = satellite.id,
name = satellite.name,
red = satellite.visualization.red,
green = satellite.visualization.green,
blue = satellite.visualization.blue,
scanTLE = satellite.scanTle,
code = satellite.code,
typeCode = satellite.typeCode,
active = satellite.active
)
}
}
@@ -0,0 +1,27 @@
package space.nstart.pcp.tle_monitoring_service.service
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.tle_monitoring_service.repository.TLERepository
@Service
class SatelliteDeletedService(
private val tleRepository: TLERepository
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@Transactional
fun deleteSatelliteData(satelliteId: Long): Long {
val beforeDelete = tleRepository.countBySatelliteId(satelliteId)
val deleted = tleRepository.deleteAllBySatelliteId(satelliteId)
logger.info(
"Deleted TLE data for satellite: satelliteId={}, tleBeforeDelete={}, tleDeleted={}",
satelliteId,
beforeDelete,
deleted
)
return deleted
}
}
@@ -0,0 +1,77 @@
package space.nstart.pcp.tle_monitoring_service.service
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.nstart.pcp.tle_monitoring_service.configuration.CustomValidationException
import space.nstart.pcp.tle_monitoring_service.configuration.TlePollingProperties
import java.time.Duration
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
data class TlePollingSettings(
val enabled: Boolean,
val timeoutSeconds: Long,
)
data class UpdateTlePollingSettingsRequest(
val enabled: Boolean?,
val timeoutSeconds: Long?,
)
@Service
class TlePollingSettingsService(properties: TlePollingProperties) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val enabled = AtomicBoolean(properties.enabled)
private val timeoutSeconds = AtomicLong(validateTimeout(properties.timeoutSeconds))
private val lastPollStartedAt = AtomicReference<Instant?>()
fun current(): TlePollingSettings =
TlePollingSettings(
enabled = enabled.get(),
timeoutSeconds = timeoutSeconds.get(),
)
fun update(request: UpdateTlePollingSettingsRequest): TlePollingSettings {
val updatedEnabled = request.enabled
?: throw CustomValidationException("enabled must be set")
val updatedTimeoutSeconds = validateTimeout(
request.timeoutSeconds ?: throw CustomValidationException("timeoutSeconds must be set")
)
val wasEnabled = enabled.getAndSet(updatedEnabled)
timeoutSeconds.set(updatedTimeoutSeconds)
if (!wasEnabled && updatedEnabled) {
lastPollStartedAt.set(null)
}
val settings = current()
logger.info(
"TLE polling settings updated: enabled={}, timeoutSeconds={}",
settings.enabled,
settings.timeoutSeconds,
)
return settings
}
fun shouldStartPolling(now: Instant = Instant.now()): Boolean {
if (!enabled.get()) {
return false
}
val lastStartedAt = lastPollStartedAt.get() ?: return true
return Duration.between(lastStartedAt, now).seconds >= timeoutSeconds.get()
}
fun markPollingStarted(now: Instant = Instant.now()) {
lastPollStartedAt.set(now)
}
private fun validateTimeout(value: Long): Long {
if (value <= 0) {
throw CustomValidationException("timeoutSeconds must be greater than zero")
}
return value
}
}
@@ -0,0 +1,18 @@
spring:
application:
name: tle-monitoring-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
tle:
polling:
enabled: ${TLE_POLLING_ENABLED:false}
timeout-seconds: ${TLE_POLLING_TIMEOUT_SECONDS:10800}
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS tle (
tle_id BIGSERIAL PRIMARY KEY,
satellite_id BIGINT NOT NULL,
revolution BIGINT NOT NULL,
tle TEXT NOT NULL
);
CREATE INDEX idx_tle_satellite_id ON tle(satellite_id);
CREATE INDEX idx_tle_revolution ON tle(revolution);
@@ -0,0 +1,2 @@
ALTER TABLE tle ADD COLUMN IF NOT EXISTS time_tle TIMESTAMP;
ALTER TABLE tle ADD COLUMN IF NOT EXISTS time_reс TIMESTAMP;
@@ -0,0 +1,13 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'tle'
AND column_name = 'time_reс'
) THEN
ALTER TABLE tle RENAME COLUMN "time_reс" TO time_rec;
END IF;
END $$;
ALTER TABLE tle ADD COLUMN IF NOT EXISTS time_rec TIMESTAMP;
@@ -0,0 +1,13 @@
package space.nstart.pcp.tle_monitoring_service
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class TleMonitoringServiceApplicationTests {
@Test
fun contextLoads() {
}
}
@@ -0,0 +1,21 @@
package space.nstart.pcp.tle_monitoring_service.service
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.tle_monitoring_service.configuration.TlePollingProperties
class CelesTrakServiceTest {
@Test
fun `findExpired does not access satellite source when polling is disabled`() {
val pollingSettingsService = TlePollingSettingsService(
TlePollingProperties(enabled = false, timeoutSeconds = 30),
)
val service = CelesTrakService(WebClient.builder().build(), pollingSettingsService)
val result = service.findExpired().collectList().block().orEmpty()
assertTrue(result.isEmpty())
}
}
@@ -0,0 +1,26 @@
package space.nstart.pcp.tle_monitoring_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import space.nstart.pcp.tle_monitoring_service.repository.TLERepository
class SatelliteDeletedServiceTest {
@Test
fun `deleteSatelliteData removes TLE rows`() {
val tleRepository = mock(TLERepository::class.java)
val service = SatelliteDeletedService(tleRepository)
`when`(tleRepository.countBySatelliteId(56756L)).thenReturn(4L)
`when`(tleRepository.deleteAllBySatelliteId(56756L)).thenReturn(4L)
val deleted = service.deleteSatelliteData(56756L)
assertEquals(4L, deleted)
verify(tleRepository).countBySatelliteId(56756L)
verify(tleRepository).deleteAllBySatelliteId(56756L)
}
}
@@ -0,0 +1,86 @@
package space.nstart.pcp.tle_monitoring_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import space.nstart.pcp.tle_monitoring_service.configuration.CustomValidationException
import space.nstart.pcp.tle_monitoring_service.configuration.TlePollingProperties
import java.time.Instant
class TlePollingSettingsServiceTest {
@Test
fun `initializes settings from properties`() {
val service = TlePollingSettingsService(
TlePollingProperties(enabled = false, timeoutSeconds = 45),
)
val settings = service.current()
assertFalse(settings.enabled)
assertEquals(45, settings.timeoutSeconds)
}
@Test
fun `polling is disabled by default`() {
val service = TlePollingSettingsService(TlePollingProperties())
val settings = service.current()
assertFalse(settings.enabled)
assertFalse(service.shouldStartPolling())
}
@Test
fun `updates polling settings`() {
val service = TlePollingSettingsService(TlePollingProperties())
val settings = service.update(
UpdateTlePollingSettingsRequest(enabled = false, timeoutSeconds = 60),
)
assertFalse(settings.enabled)
assertEquals(60, settings.timeoutSeconds)
assertFalse(service.shouldStartPolling())
}
@Test
fun `uses timeout between polling cycles`() {
val service = TlePollingSettingsService(
TlePollingProperties(enabled = true, timeoutSeconds = 30),
)
val startedAt = Instant.parse("2026-05-13T10:00:00Z")
assertTrue(service.shouldStartPolling(startedAt))
service.markPollingStarted(startedAt)
assertFalse(service.shouldStartPolling(startedAt.plusSeconds(29)))
assertTrue(service.shouldStartPolling(startedAt.plusSeconds(30)))
}
@Test
fun `rejects invalid timeout`() {
val service = TlePollingSettingsService(TlePollingProperties())
assertThrows(CustomValidationException::class.java) {
service.update(UpdateTlePollingSettingsRequest(enabled = true, timeoutSeconds = 0))
}
}
@Test
fun `starts immediately after polling is enabled`() {
val service = TlePollingSettingsService(
TlePollingProperties(enabled = true, timeoutSeconds = 30),
)
val startedAt = Instant.parse("2026-05-13T10:00:00Z")
service.markPollingStarted(startedAt)
service.update(UpdateTlePollingSettingsRequest(enabled = false, timeoutSeconds = 30))
service.update(UpdateTlePollingSettingsRequest(enabled = true, timeoutSeconds = 30))
assertTrue(service.shouldStartPolling(startedAt.plusSeconds(1)))
}
}
@@ -0,0 +1,73 @@
spring:
application:
name: tle-monitoring-service
cloud:
config:
enabled: false
kafka:
bootstrap-servers: 192.168.60.68:29092
consumer:
group-id: pcp
auto-offset-reset: earliest
template:
default-topic: pcp.tle
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.60.68:5432/pcp_tle
username: postgres
password: password
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
lob:
non_contextual_creation: true
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
lifecycle.timeout-per-shutdown-phase: 40s
jackson:
default-property-inclusion: non_null
codec:
max-in-memory-size: 20MB
springdoc:
swagger-ui:
enabled: true
layout: BaseLayout
path: /swagger/ui
api-docs:
enabled: true
path: /api-docs
logging:
level:
.: ERROR
file:
name: ./logs/application.log
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
info:
enabled: true
settings:
complex-mission-service: http://192.168.60.68:7002
tle:
polling:
enabled: false
timeout-seconds: 10800
server:
port: 7001