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,11 @@
package space.nstart.pcp.pcp_satellite_catalog_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class PcpSatelliteCatalogServiceApplication
fun main(args: Array<String>) {
runApplication<PcpSatelliteCatalogServiceApplication>(*args)
}
@@ -0,0 +1,27 @@
package space.nstart.pcp.pcp_satellite_catalog_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
class CustomValidationException(message: String) : RuntimeException(message)
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(CustomValidationException::class)
fun handleValidation(ex: CustomValidationException): ResponseEntity<Map<String, String>> =
ResponseEntity.badRequest().body(mapOf("error" to (ex.message ?: "validation error")))
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
val errors = mutableMapOf<String, String>()
ex.bindingResult.allErrors.forEach { error ->
val fieldName = (error as? FieldError)?.field ?: "request"
errors[fieldName] = error.defaultMessage ?: "Validation error"
}
return ResponseEntity.badRequest().body(errors)
}
}
@@ -0,0 +1,49 @@
package space.nstart.pcp.pcp_satellite_catalog_service.configuration
import org.apache.kafka.clients.admin.AdminClientConfig
import org.apache.kafka.clients.admin.NewTopic
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.KafkaAdmin
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
@Configuration
class KafkaConfig {
@Value("\${spring.kafka.bootstrap-servers}")
private lateinit var bootstrapServers: String
@Value("\${app.kafka.topics.satellites:pcp.satellites}")
private lateinit var satelliteEventsTopicName: String
@Bean
fun satelliteEventsTopic(): NewTopic = NewTopic(satelliteEventsTopicName, 1, 1)
@Bean
fun kafkaAdmin(): KafkaAdmin =
KafkaAdmin(
mutableMapOf<String, Any>(
AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers
)
)
@Bean
fun producerFactory(): ProducerFactory<String, Any> =
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, Any> = KafkaTemplate(producerFactory())
}
@@ -0,0 +1,12 @@
package space.nstart.pcp.pcp_satellite_catalog_service.configuration
import jakarta.validation.Validation
import jakarta.validation.Validator
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class ValidationConfig {
@Bean
fun validator(): Validator = Validation.buildDefaultValidatorFactory().validator
}
@@ -0,0 +1,100 @@
package space.nstart.pcp.pcp_satellite_catalog_service.controller
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
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.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.pcp_satellite_catalog_service.service.SatelliteCatalogService
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO
@RestController
@RequestMapping("/api/satellites")
class SatelliteCatalogController(
private val satelliteCatalogService: SatelliteCatalogService
) {
@GetMapping
fun all() = satelliteCatalogService.all()
@GetMapping("/{satellite_id}")
fun byId(@PathVariable("satellite_id") satelliteId: Long) =
satelliteCatalogService.byId(satelliteId)
@GetMapping("/by-norad/{norad_id}")
fun byNoradId(@PathVariable("norad_id") noradId: Long) =
satelliteCatalogService.byNoradId(noradId)
@PostMapping
fun create(@Valid @RequestBody request: SatelliteCreateDTO) =
satelliteCatalogService.createSatellite(request)
@PutMapping("/{satellite_id}")
fun update(
@PathVariable("satellite_id") satelliteId: Long,
@Valid @RequestBody request: SatelliteUpdateDTO
) = satelliteCatalogService.updateSatellite(satelliteId, request)
@DeleteMapping("/{satellite_id}")
fun delete(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteSatellite(satelliteId)
return ResponseEntity.noContent().build()
}
@GetMapping("/{satellite_id}/observation-profile")
fun observationProfile(@PathVariable("satellite_id") satelliteId: Long) =
satelliteCatalogService.observationProfile(satelliteId)
@PostMapping("/{satellite_id}/observation-profile")
fun createObservationProfile(
@PathVariable("satellite_id") satelliteId: Long,
@Valid @RequestBody request: SatelliteObservationProfileDTO
) = satelliteCatalogService.createObservationProfile(satelliteId, request)
@PutMapping("/{satellite_id}/observation-profile")
fun updateObservationProfile(
@PathVariable("satellite_id") satelliteId: Long,
@Valid @RequestBody request: SatelliteObservationProfileDTO
) = satelliteCatalogService.updateObservationProfile(satelliteId, request)
@DeleteMapping("/{satellite_id}/observation-profile")
fun deleteObservationProfile(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteObservationProfile(satelliteId)
return ResponseEntity.noContent().build()
}
@GetMapping("/{satellite_id}/slot-profile")
fun slotProfile(@PathVariable("satellite_id") satelliteId: Long) =
satelliteCatalogService.slotProfile(satelliteId)
@PostMapping("/{satellite_id}/slot-profile")
fun createSlotProfile(
@PathVariable("satellite_id") satelliteId: Long,
@Valid @RequestBody request: SatelliteSlotProfileDTO
) = satelliteCatalogService.createSlotProfile(satelliteId, request)
@PutMapping("/{satellite_id}/slot-profile")
fun updateSlotProfile(
@PathVariable("satellite_id") satelliteId: Long,
@Valid @RequestBody request: SatelliteSlotProfileDTO
) = satelliteCatalogService.updateSlotProfile(satelliteId, request)
@DeleteMapping("/{satellite_id}/slot-profile")
fun deleteSlotProfile(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteSlotProfile(satelliteId)
return ResponseEntity.noContent().build()
}
@PostMapping("/batch")
fun batch(@Valid @RequestBody request: SatelliteBatchRequestDTO) =
satelliteCatalogService.batch(request)
}
@@ -0,0 +1,45 @@
package space.nstart.pcp.pcp_satellite_catalog_service.controller
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
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.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.pcp_satellite_catalog_service.service.SatelliteCatalogService
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupUpdateDTO
@RestController
@RequestMapping("/api/satellite-groups")
class SatelliteGroupController(
private val satelliteCatalogService: SatelliteCatalogService
) {
@GetMapping
fun all() = satelliteCatalogService.allGroups()
@GetMapping("/{group_id}")
fun byId(@PathVariable("group_id") groupId: Long) =
satelliteCatalogService.groupById(groupId)
@PostMapping
fun create(@Valid @RequestBody request: SatelliteGroupCreateDTO) =
satelliteCatalogService.createGroup(request)
@PutMapping("/{group_id}")
fun update(
@PathVariable("group_id") groupId: Long,
@Valid @RequestBody request: SatelliteGroupUpdateDTO
) = satelliteCatalogService.updateGroup(groupId, request)
@DeleteMapping("/{group_id}")
fun delete(@PathVariable("group_id") groupId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteGroup(groupId)
return ResponseEntity.noContent().build()
}
}
@@ -0,0 +1,48 @@
package space.nstart.pcp.pcp_satellite_catalog_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
@Entity
@Table(name = "satellite")
class SatelliteEntity(
@Id
val id: Long = 0,
@Column(name = "norad_id")
val noradId: Long? = null,
@Column(nullable = false)
val code: String = "",
@Column(nullable = false)
val name: String = "",
@Column(name = "type_code", nullable = false)
val typeCode: String = "",
@Column(nullable = false)
val active: Boolean = true,
@Column(name = "scan_tle", nullable = false)
val scanTle: Boolean = false,
@Column(nullable = false)
val red: Short = 255,
@Column(nullable = false)
val green: Short = 0,
@Column(nullable = false)
val blue: Short = 0
) {
fun toSummaryDTO() = SatelliteSummaryDTO(
id = id,
noradId = noradId,
code = code,
name = name,
typeCode = typeCode,
active = active,
scanTle = scanTle,
visualization = SatelliteVisualizationDTO(
red = red,
green = green,
blue = blue
)
)
}
@@ -0,0 +1,18 @@
package space.nstart.pcp.pcp_satellite_catalog_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "satellite_group")
class SatelliteGroupEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val name: String = ""
)
@@ -0,0 +1,25 @@
package space.nstart.pcp.pcp_satellite_catalog_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import java.io.Serializable
@Entity
@Table(name = "satellite_group_satellite")
@IdClass(SatelliteGroupSatelliteId::class)
class SatelliteGroupSatelliteEntity(
@Id
@Column(name = "group_id")
val groupId: Long = 0,
@Id
@Column(name = "satellite_id")
val satelliteId: Long = 0
)
data class SatelliteGroupSatelliteId(
val groupId: Long = 0,
val satelliteId: Long = 0
) : Serializable
@@ -0,0 +1,39 @@
package space.nstart.pcp.pcp_satellite_catalog_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
@Entity
@Table(name = "satellite_observation_profile")
class SatelliteObservationProfileEntity(
@Id
@Column(name = "satellite_id")
val satelliteId: Long = 0,
@Column(name = "capture_angle", nullable = false)
val captureAngle: Double = 1.5,
@Column(name = "sun_angle_min", nullable = false)
val sunAngleMin: Double = -90.0,
@Column(name = "duration_min_seconds", nullable = false)
val durationMinSeconds: Long = 10,
@Column(name = "duration_max_seconds", nullable = false)
val durationMaxSeconds: Long = 300,
@Column(name = "mmi_seconds", nullable = false)
val mmiSeconds: Long = 10,
@Column(name = "daily_max_duration_seconds", nullable = false)
val dailyMaxDurationSeconds: Long = 35 * 60,
@Column(name = "revolution_max_duration_seconds", nullable = false)
val revolutionMaxDurationSeconds: Long = 5 * 60
) {
fun toDTO() = SatelliteObservationProfileDTO(
captureAngle = captureAngle,
sunAngleMin = sunAngleMin,
durationMinSeconds = durationMinSeconds,
durationMaxSeconds = durationMaxSeconds,
mmiSeconds = mmiSeconds,
dailyMaxDurationSeconds = dailyMaxDurationSeconds,
revolutionMaxDurationSeconds = revolutionMaxDurationSeconds
)
}
@@ -0,0 +1,29 @@
package space.nstart.pcp.pcp_satellite_catalog_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import java.io.Serializable
@Entity
@Table(name = "satellite_slot_angle")
@IdClass(SatelliteSlotAngleId::class)
class SatelliteSlotAngleEntity(
@Id
@Column(name = "satellite_id")
val satelliteId: Long = 0,
@Id
@Column(name = "position")
val position: Int = 0,
@Column(nullable = false)
val angleBegin: Double = 0.0,
@Column(nullable = false)
val angleEnd: Double = 0.0
)
data class SatelliteSlotAngleId(
val satelliteId: Long = 0,
val position: Int = 0
) : Serializable
@@ -0,0 +1,25 @@
package space.nstart.pcp.pcp_satellite_catalog_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
@Entity
@Table(name = "satellite_slot_profile")
class SatelliteSlotProfileEntity(
@Id
@Column(name = "satellite_id")
val satelliteId: Long = 0,
@Column(name = "cycle_revs", nullable = false)
val cycleRevs: Long = 0,
@Column(name = "tn_calc")
val tnCalc: LocalDateTime? = null,
@Column(name = "slot_duration", nullable = false)
val slotDuration: Long = 10,
@Column(name = "duration_calc_days", nullable = false)
val durationCalcDays: Long = 0,
@Column(name = "max_chain_length", nullable = false)
val maxChainLength: Int = 0
)
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_satellite_catalog_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupEntity
interface SatelliteGroupRepository : JpaRepository<SatelliteGroupEntity, Long> {
fun findAllByOrderByIdAsc(): List<SatelliteGroupEntity>
fun existsByName(name: String): Boolean
fun existsByNameAndIdNot(name: String, id: Long): Boolean
}
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_satellite_catalog_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupSatelliteEntity
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupSatelliteId
interface SatelliteGroupSatelliteRepository : JpaRepository<SatelliteGroupSatelliteEntity, SatelliteGroupSatelliteId> {
fun findAllByGroupIdInOrderByGroupIdAscSatelliteIdAsc(groupIds: Collection<Long>): List<SatelliteGroupSatelliteEntity>
fun deleteAllByGroupId(groupId: Long)
}
@@ -0,0 +1,8 @@
package space.nstart.pcp.pcp_satellite_catalog_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteObservationProfileEntity
interface SatelliteObservationProfileRepository : JpaRepository<SatelliteObservationProfileEntity, Long> {
fun findAllBySatelliteIdIn(satelliteIds: Collection<Long>): List<SatelliteObservationProfileEntity>
}
@@ -0,0 +1,14 @@
package space.nstart.pcp.pcp_satellite_catalog_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteEntity
interface SatelliteRepository : JpaRepository<SatelliteEntity, Long> {
fun findAllByOrderByIdAsc(): List<SatelliteEntity>
fun findByNoradId(noradId: Long): SatelliteEntity?
fun findAllByIdIn(ids: Collection<Long>): List<SatelliteEntity>
fun existsByNoradId(noradId: Long): Boolean
fun existsByNoradIdAndIdNot(noradId: Long, id: Long): Boolean
fun existsByCode(code: String): Boolean
fun existsByCodeAndIdNot(code: String, id: Long): Boolean
}
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_satellite_catalog_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotAngleEntity
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotAngleId
interface SatelliteSlotAngleRepository : JpaRepository<SatelliteSlotAngleEntity, SatelliteSlotAngleId> {
fun findAllBySatelliteIdInOrderBySatelliteIdAscPositionAsc(satelliteIds: Collection<Long>): List<SatelliteSlotAngleEntity>
fun deleteAllBySatelliteId(satelliteId: Long)
}
@@ -0,0 +1,8 @@
package space.nstart.pcp.pcp_satellite_catalog_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotProfileEntity
interface SatelliteSlotProfileRepository : JpaRepository<SatelliteSlotProfileEntity, Long> {
fun findAllBySatelliteIdIn(satelliteIds: Collection<Long>): List<SatelliteSlotProfileEntity>
}
@@ -0,0 +1,412 @@
package space.nstart.pcp.pcp_satellite_catalog_service.service
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import space.nstart.pcp.pcp_satellite_catalog_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteEntity
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupEntity
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupSatelliteEntity
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteObservationProfileEntity
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotAngleEntity
import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotProfileEntity
import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteGroupRepository
import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteGroupSatelliteRepository
import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteObservationProfileRepository
import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteRepository
import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteSlotAngleRepository
import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteSlotProfileRepository
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupUpdateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO
@Service
class SatelliteCatalogService(
private val satelliteRepository: SatelliteRepository,
private val observationProfileRepository: SatelliteObservationProfileRepository,
private val slotProfileRepository: SatelliteSlotProfileRepository,
private val slotAngleRepository: SatelliteSlotAngleRepository,
private val satelliteGroupRepository: SatelliteGroupRepository,
private val satelliteGroupSatelliteRepository: SatelliteGroupSatelliteRepository,
private val satelliteDeletedKafkaPublisher: SatelliteDeletedKafkaPublisher
) {
private val logger = LoggerFactory.getLogger(this::class.java)
fun all(): List<SatelliteSummaryDTO> =
satelliteRepository.findAllByOrderByIdAsc().map { it.toSummaryDTO() }
fun byId(id: Long): SatelliteDTO =
assembleSatellites(listOf(requireSatellite(id))).getValue(id)
fun byNoradId(noradId: Long): SatelliteDTO {
val satellite = satelliteRepository.findByNoradId(noradId)
?: throw CustomValidationException("Satellite with NORAD ID $noradId not found")
return assembleSatellites(listOf(satellite)).getValue(satellite.id)
}
fun observationProfile(id: Long): SatelliteObservationProfileDTO {
requireSatellite(id)
return observationProfileRepository.findById(id).orElseThrow {
CustomValidationException("Observation profile for satellite $id not found")
}.toDTO()
}
fun slotProfile(id: Long): SatelliteSlotProfileDTO {
requireSatellite(id)
return assembleSlotProfiles(listOf(id))[id]
?: throw CustomValidationException("Slot profile for satellite $id not found")
}
fun batch(request: SatelliteBatchRequestDTO): List<SatelliteDTO> {
val uniqueIds = request.ids.distinct()
if (uniqueIds.isEmpty()) {
return emptyList()
}
val satellites = satelliteRepository.findAllByIdIn(uniqueIds)
val byId = assembleSatellites(satellites)
return uniqueIds.map { id ->
byId[id] ?: throw CustomValidationException("Satellite $id not found")
}
}
@Transactional
fun createSatellite(request: SatelliteCreateDTO): SatelliteDTO {
validateSatelliteCreate(request)
satelliteRepository.save(request.toEntity())
return byId(request.id)
}
@Transactional
fun updateSatellite(id: Long, request: SatelliteUpdateDTO): SatelliteDTO {
requireSatellite(id)
validateSatelliteUpdate(id, request)
satelliteRepository.save(request.toEntity(id))
return byId(id)
}
@Transactional
fun deleteSatellite(id: Long) {
val satellite = requireSatellite(id)
val event = SatelliteDeletedEventDTO(
satelliteId = satellite.id,
noradId = satellite.noradId,
code = satellite.code,
name = satellite.name
)
satelliteRepository.deleteById(id)
logger.info(
"Deleted satellite from catalog: satelliteId={}, noradId={}, code={}, name={}",
event.satelliteId,
event.noradId,
event.code,
event.name
)
publishSatelliteDeletedAfterCommit(event)
}
private fun publishSatelliteDeletedAfterCommit(event: SatelliteDeletedEventDTO) {
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
satelliteDeletedKafkaPublisher.publish(event)
}
}
)
}
@Transactional
fun createObservationProfile(id: Long, request: SatelliteObservationProfileDTO): SatelliteObservationProfileDTO {
requireSatellite(id)
if (observationProfileRepository.existsById(id)) {
throw CustomValidationException("Observation profile for satellite $id already exists")
}
return observationProfileRepository.save(request.toEntity(id)).toDTO()
}
@Transactional
fun updateObservationProfile(id: Long, request: SatelliteObservationProfileDTO): SatelliteObservationProfileDTO {
requireSatellite(id)
if (!observationProfileRepository.existsById(id)) {
throw CustomValidationException("Observation profile for satellite $id not found")
}
return observationProfileRepository.save(request.toEntity(id)).toDTO()
}
@Transactional
fun deleteObservationProfile(id: Long) {
requireSatellite(id)
if (!observationProfileRepository.existsById(id)) {
throw CustomValidationException("Observation profile for satellite $id not found")
}
observationProfileRepository.deleteById(id)
}
@Transactional
fun createSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO {
requireSatellite(id)
if (slotProfileRepository.existsById(id)) {
throw CustomValidationException("Slot profile for satellite $id already exists")
}
return saveSlotProfile(id, request)
}
@Transactional
fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO {
requireSatellite(id)
if (!slotProfileRepository.existsById(id)) {
throw CustomValidationException("Slot profile for satellite $id not found")
}
return saveSlotProfile(id, request)
}
@Transactional
fun deleteSlotProfile(id: Long) {
requireSatellite(id)
if (!slotProfileRepository.existsById(id)) {
throw CustomValidationException("Slot profile for satellite $id not found")
}
slotAngleRepository.deleteAllBySatelliteId(id)
slotProfileRepository.deleteById(id)
}
fun allGroups(): List<SatelliteGroupDTO> =
assembleGroups(satelliteGroupRepository.findAllByOrderByIdAsc()).values.toList()
fun groupById(id: Long): SatelliteGroupDTO =
assembleGroups(listOf(requireGroup(id))).getValue(id)
@Transactional
fun createGroup(request: SatelliteGroupCreateDTO): SatelliteGroupDTO {
validateGroupCreate(request)
val satelliteIds = request.satelliteIds.distinct()
validateSatelliteIdsExist(satelliteIds)
val group = satelliteGroupRepository.save(SatelliteGroupEntity(name = request.name))
saveGroupSatellites(group.id, satelliteIds)
return groupById(group.id)
}
@Transactional
fun updateGroup(id: Long, request: SatelliteGroupUpdateDTO): SatelliteGroupDTO {
requireGroup(id)
validateGroupUpdate(id, request)
val satelliteIds = request.satelliteIds.distinct()
validateSatelliteIdsExist(satelliteIds)
satelliteGroupRepository.save(SatelliteGroupEntity(id = id, name = request.name))
satelliteGroupSatelliteRepository.deleteAllByGroupId(id)
saveGroupSatellites(id, satelliteIds)
return groupById(id)
}
@Transactional
fun deleteGroup(id: Long) {
requireGroup(id)
satelliteGroupRepository.deleteById(id)
}
private fun saveSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO {
slotProfileRepository.save(request.toEntity(id))
slotAngleRepository.deleteAllBySatelliteId(id)
slotAngleRepository.saveAll(
request.defaultAngles.mapIndexed { index, angle ->
SatelliteSlotAngleEntity(
satelliteId = id,
position = index + 1,
angleBegin = angle.angleBegin,
angleEnd = angle.angleEnd
)
}
)
return assembleSlotProfiles(listOf(id)).getValue(id)
}
private fun saveGroupSatellites(groupId: Long, satelliteIds: List<Long>) {
if (satelliteIds.isEmpty()) {
return
}
satelliteGroupSatelliteRepository.saveAll(
satelliteIds.map { satelliteId ->
SatelliteGroupSatelliteEntity(
groupId = groupId,
satelliteId = satelliteId
)
}
)
}
private fun validateSatelliteCreate(request: SatelliteCreateDTO) {
if (satelliteRepository.existsById(request.id)) {
throw CustomValidationException("Satellite ${request.id} already exists")
}
if (satelliteRepository.existsByCode(request.code)) {
throw CustomValidationException("Satellite code ${request.code} already exists")
}
request.noradId?.let { noradId ->
if (satelliteRepository.existsByNoradId(noradId)) {
throw CustomValidationException("Satellite with NORAD ID $noradId already exists")
}
}
}
private fun validateSatelliteUpdate(id: Long, request: SatelliteUpdateDTO) {
if (satelliteRepository.existsByCodeAndIdNot(request.code, id)) {
throw CustomValidationException("Satellite code ${request.code} already exists")
}
request.noradId?.let { noradId ->
if (satelliteRepository.existsByNoradIdAndIdNot(noradId, id)) {
throw CustomValidationException("Satellite with NORAD ID $noradId already exists")
}
}
}
private fun validateGroupCreate(request: SatelliteGroupCreateDTO) {
if (satelliteGroupRepository.existsByName(request.name)) {
throw CustomValidationException("Satellite group ${request.name} already exists")
}
}
private fun validateGroupUpdate(id: Long, request: SatelliteGroupUpdateDTO) {
if (satelliteGroupRepository.existsByNameAndIdNot(request.name, id)) {
throw CustomValidationException("Satellite group ${request.name} already exists")
}
}
private fun validateSatelliteIdsExist(ids: List<Long>) {
if (ids.isEmpty()) {
return
}
val existingIds = satelliteRepository.findAllByIdIn(ids).map { it.id }.toSet()
val missingIds = ids.filterNot(existingIds::contains)
if (missingIds.isNotEmpty()) {
throw CustomValidationException("Satellites not found: ${missingIds.joinToString(", ")}")
}
}
private fun requireSatellite(id: Long): SatelliteEntity =
satelliteRepository.findById(id).orElseThrow {
CustomValidationException("Satellite $id not found")
}
private fun requireGroup(id: Long): SatelliteGroupEntity =
satelliteGroupRepository.findById(id).orElseThrow {
CustomValidationException("Satellite group $id not found")
}
private fun assembleSatellites(satellites: List<SatelliteEntity>): Map<Long, SatelliteDTO> {
val ids = satellites.map { it.id }
val observationProfiles = observationProfileRepository.findAllBySatelliteIdIn(ids)
.associateBy { it.satelliteId }
val slotProfiles = assembleSlotProfiles(ids)
return satellites.associate { satellite ->
val observationProfile = observationProfiles[satellite.id]?.toDTO()
satellite.id to SatelliteDTO(
id = satellite.id,
noradId = satellite.noradId,
code = satellite.code,
name = satellite.name,
typeCode = satellite.typeCode,
active = satellite.active,
scanTle = satellite.scanTle,
visualization = satellite.toSummaryDTO().visualization,
observationProfile = observationProfile,
slotProfile = slotProfiles[satellite.id]
)
}
}
private fun assembleSlotProfiles(ids: List<Long>): Map<Long, SatelliteSlotProfileDTO> {
val slotProfiles = slotProfileRepository.findAllBySatelliteIdIn(ids)
.associateBy { it.satelliteId }
val anglesBySatelliteId = slotAngleRepository.findAllBySatelliteIdInOrderBySatelliteIdAscPositionAsc(ids)
.groupBy({ it.satelliteId }) {
SatelliteSlotAngleDTO(
angleBegin = it.angleBegin,
angleEnd = it.angleEnd
)
}
return slotProfiles.mapValues { (_, profile) ->
SatelliteSlotProfileDTO(
cycleRevs = profile.cycleRevs,
tnCalc = profile.tnCalc,
slotDuration = profile.slotDuration,
durationCalcDays = profile.durationCalcDays,
maxChainLength = profile.maxChainLength,
defaultAngles = anglesBySatelliteId[profile.satelliteId].orEmpty()
)
}
}
private fun assembleGroups(groups: List<SatelliteGroupEntity>): Map<Long, SatelliteGroupDTO> {
val groupIds = groups.map { it.id }
val memberships = satelliteGroupSatelliteRepository.findAllByGroupIdInOrderByGroupIdAscSatelliteIdAsc(groupIds)
.groupBy({ it.groupId }, { it.satelliteId })
return groups.associate { group ->
group.id to SatelliteGroupDTO(
id = group.id,
name = group.name,
satelliteIds = memberships[group.id].orEmpty()
)
}
}
private fun SatelliteCreateDTO.toEntity() = SatelliteEntity(
id = id,
noradId = noradId,
code = code,
name = name,
typeCode = typeCode,
active = active,
scanTle = scanTle,
red = visualization.red,
green = visualization.green,
blue = visualization.blue
)
private fun SatelliteUpdateDTO.toEntity(id: Long) = SatelliteEntity(
id = id,
noradId = noradId,
code = code,
name = name,
typeCode = typeCode,
active = active,
scanTle = scanTle,
red = visualization.red,
green = visualization.green,
blue = visualization.blue
)
private fun SatelliteObservationProfileDTO.toEntity(satelliteId: Long) = SatelliteObservationProfileEntity(
satelliteId = satelliteId,
captureAngle = captureAngle,
sunAngleMin = sunAngleMin,
durationMinSeconds = durationMinSeconds,
durationMaxSeconds = durationMaxSeconds,
mmiSeconds = mmiSeconds,
dailyMaxDurationSeconds = dailyMaxDurationSeconds,
revolutionMaxDurationSeconds = revolutionMaxDurationSeconds
)
private fun SatelliteSlotProfileDTO.toEntity(satelliteId: Long) = SatelliteSlotProfileEntity(
satelliteId = satelliteId,
cycleRevs = cycleRevs,
tnCalc = tnCalc,
slotDuration = slotDuration,
durationCalcDays = durationCalcDays,
maxChainLength = maxChainLength
)
}
@@ -0,0 +1,69 @@
package space.nstart.pcp.pcp_satellite_catalog_service.service
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.core.KafkaTemplate
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.pcp_types_lib.message.PcpKafkaEvent
import java.nio.charset.StandardCharsets
@Component
class SatelliteDeletedKafkaPublisher(
kafkaTemplateProvider: ObjectProvider<KafkaTemplate<String, Any>>,
objectMapperProvider: ObjectProvider<ObjectMapper>,
@param:Value("\${app.kafka.topics.satellites:pcp.satellites}") private val topic: String,
@param:Value("\${spring.application.name:pcp-satellite-catalog-service}") private val applicationName: String
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val kafkaTemplate = kafkaTemplateProvider.ifAvailable
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
fun publish(event: SatelliteDeletedEventDTO) {
val template = kafkaTemplate
if (template == null || topic.isBlank()) {
logger.warn(
"SatelliteDeletedEvent was not published because Kafka is not configured: satelliteId={}, noradId={}, topic={}",
event.satelliteId,
event.noradId,
topic
)
return
}
val message = KafkaMessage(
type = PcpKafkaEvent.SatelliteDeletedEvent,
data = event
).apply {
source = applicationName
}
val payload = try {
objectMapper.writeValueAsString(message)
} catch (exception: JsonProcessingException) {
throw IllegalStateException("Failed to serialize SatelliteDeletedEvent for satellite ${event.satelliteId}", exception)
}
val record = ProducerRecord<String, Any>(topic, event.satelliteId.toString(), payload)
record.headers().add(TYPE_HEADER, PcpKafkaEvent.SatelliteDeletedEvent.name.toByteArray(StandardCharsets.UTF_8))
template.send(record)
logger.info(
"Published SatelliteDeletedEvent: satelliteId={}, noradId={}, topic={}, eventType={}",
event.satelliteId,
event.noradId,
topic,
PcpKafkaEvent.SatelliteDeletedEvent
)
}
companion object {
private const val TYPE_HEADER = "type"
}
}
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-satellite-catalog-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}
@@ -0,0 +1,300 @@
CREATE TABLE IF NOT EXISTS satellite (
id BIGINT PRIMARY KEY,
norad_id BIGINT NULL,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
type_code VARCHAR(64) NOT NULL,
active BOOLEAN NOT NULL,
scan_tle BOOLEAN NOT NULL,
red SMALLINT NOT NULL,
green SMALLINT NOT NULL,
blue SMALLINT NOT NULL
);
CREATE TABLE IF NOT EXISTS satellite_observation_profile (
satellite_id BIGINT PRIMARY KEY REFERENCES satellite(id) ON DELETE CASCADE,
capture_angle DOUBLE PRECISION NOT NULL,
sun_angle_min DOUBLE PRECISION NOT NULL,
duration_min_seconds BIGINT NOT NULL,
duration_max_seconds BIGINT NOT NULL,
mmi_seconds BIGINT NOT NULL,
daily_max_duration_seconds BIGINT NOT NULL,
revolution_max_duration_seconds BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS satellite_slot_profile (
satellite_id BIGINT PRIMARY KEY REFERENCES satellite(id) ON DELETE CASCADE,
cycle_revs BIGINT NOT NULL,
tn_calc TIMESTAMP NULL,
duration_calc_days BIGINT NOT NULL,
max_chain_length INT NOT NULL
);
CREATE TABLE IF NOT EXISTS satellite_slot_angle (
satellite_id BIGINT NOT NULL REFERENCES satellite(id) ON DELETE CASCADE,
position INT NOT NULL,
angle_begin DOUBLE PRECISION NOT NULL,
angle_end DOUBLE PRECISION NOT NULL,
PRIMARY KEY (satellite_id, position)
);
CREATE INDEX IF NOT EXISTS idx_satellite_norad_id ON satellite(norad_id);
CREATE INDEX IF NOT EXISTS idx_satellite_slot_angle_satellite_id ON satellite_slot_angle(satellite_id);
INSERT INTO satellite (id, norad_id, code, name, type_code, active, scan_tle, red, green, blue) VALUES
(1, NULL, 'EMISSIO-01', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0),
(2, NULL, 'EMISSIO-02', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0),
(3, NULL, 'EMISSIO-03', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0),
(4, NULL, 'EMISSIO-04', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0),
(5, NULL, 'EMISSIO-05', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0),
(6, NULL, 'EMISSIO-06', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0),
(7, NULL, 'REFLEXIO-07', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78),
(8, NULL, 'REFLEXIO-08', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78),
(9, NULL, 'REFLEXIO-09', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78),
(10, NULL, 'REFLEXIO-10', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78),
(11, NULL, 'REFLEXIO-11', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78),
(12, NULL, 'REFLEXIO-12', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86),
(13, NULL, 'REFLEXIO-13', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86),
(14, NULL, 'REFLEXIO-14', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86),
(15, NULL, 'REFLEXIO-15', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86),
(16, NULL, 'REFLEXIO-16', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86),
(17, NULL, 'REFLEXIO-17', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94),
(18, NULL, 'REFLEXIO-18', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94),
(19, NULL, 'REFLEXIO-19', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94),
(20, NULL, 'REFLEXIO-20', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94),
(21, NULL, 'REFLEXIO-21', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94),
(22, NULL, 'EMISSIO-22', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0),
(666, NULL, 'REFLEXIO-EX-01', 'Reflexio_ex', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 255, 0, 0),
(667, NULL, 'REFLEXIO-EX-02', 'Reflexio_ex2', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 0, 255, 0),
(668, NULL, 'REFLEXIO-EX-03', 'Reflexio_ex3', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 0, 0, 255),
(669, NULL, 'REFLEXIO-EX-04', 'Reflexio_ex4', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 255, 255, 0),
(56756, 56756, 'KONDOR-FKA-01', 'KONDOR-FKA NO. 1', 'KONDOR_FKA', TRUE, TRUE, 255, 0, 0),
(62138, 62138, 'KONDOR-FKA-02', 'KONDOR-FKA NO. 2', 'KONDOR_FKA', TRUE, TRUE, 255, 125, 0);
INSERT INTO satellite_observation_profile (
satellite_id,
capture_angle,
sun_angle_min,
duration_min_seconds,
duration_max_seconds,
mmi_seconds,
daily_max_duration_seconds,
revolution_max_duration_seconds
)
SELECT
id,
1.5,
-90.0,
10,
300,
10,
2100,
300
FROM satellite;
INSERT INTO satellite_slot_profile (satellite_id, cycle_revs, tn_calc, duration_calc_days, max_chain_length) VALUES
(1, 275, '2026-03-20T07:20:00', 18, 3),
(2, 275, '2026-03-20T07:20:00', 18, 3),
(3, 275, '2026-03-20T07:20:00', 18, 3),
(4, 275, '2026-03-20T07:20:00', 18, 3),
(5, 275, '2026-03-20T07:20:00', 18, 3),
(6, 275, '2026-03-20T07:20:00', 18, 3),
(7, 275, '2026-03-20T03:00:00', 18, 3),
(8, 275, '2026-03-20T03:18:50.782779625', 18, 3),
(9, 275, '2026-03-20T03:37:40.764922495', 18, 3),
(10, 275, '2026-03-20T03:56:32.881376251', 18, 3),
(11, 275, '2026-03-20T04:15:27.865948215', 18, 3),
(12, 275, '2026-03-20T04:36:17.199469748', 18, 3),
(13, 275, '2026-03-20T04:36:17.199469748', 18, 3),
(14, 275, '2026-03-20T04:36:17.199469748', 18, 3),
(15, 275, '2026-03-20T04:36:17.199469748', 18, 3),
(16, 275, '2026-03-20T04:36:17.199469748', 18, 3),
(17, 275, '2026-03-20T06:12:34.090912147', 18, 3),
(18, 275, '2026-03-20T06:12:34.090912147', 18, 3),
(19, 275, '2026-03-20T06:12:34.090912147', 18, 3),
(20, 275, '2026-03-20T06:12:34.090912147', 18, 3),
(21, 275, '2026-03-20T06:12:34.090912147', 18, 3),
(22, 275, '2026-03-20T07:20:00', 18, 3),
(666, 500, '2028-06-01T00:00:00', 31, 3),
(667, 500, '2028-06-01T00:00:00', 31, 3),
(668, 500, '2028-06-01T00:00:00', 16, 3),
(669, 500, '2028-06-01T00:00:00', 16, 3),
(56756, 243, '2025-10-27T01:27:51.000031078', 16, 2),
(62138, 243, '2025-10-27T00:29:23.162630', 16, 3);
INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end)
SELECT satellite_id, position, angle_begin, angle_end
FROM (VALUES
(56756),
(62138)
) AS satellites(satellite_id)
CROSS JOIN (VALUES
(1, 20.00, 23.00),
(2, 23.00, 26.00),
(3, 26.00, 29.00),
(4, 29.00, 32.00),
(5, 32.00, 35.00),
(6, 35.00, 38.00),
(7, 38.00, 41.00),
(8, 41.00, 44.00),
(9, 44.00, 47.00),
(10, 47.00, 50.00)
) AS angles(position, angle_begin, angle_end);
INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end)
SELECT satellite_id, position, angle_begin, angle_end
FROM (VALUES
(1),
(2),
(3),
(4),
(5),
(6),
(22)
) AS satellites(satellite_id)
CROSS JOIN (VALUES
(1, 18.50, 21.58),
(2, 20.85, 23.82),
(3, 23.12, 25.98),
(4, 25.30, 28.05),
(5, 27.40, 30.03),
(6, 29.41, 31.92),
(7, 31.33, 33.73),
(8, 33.16, 35.45),
(9, 34.91, 37.08),
(10, 36.57, 38.63),
(11, 38.15, 40.11),
(12, 39.65, 41.52),
(13, 41.08, 42.85),
(14, 42.43, 44.11),
(15, 43.71, 45.30),
(16, 44.93, 46.44),
(17, 46.08, 47.51),
(18, 47.18, 48.54),
(19, 48.22, 49.51),
(20, 49.20, 50.42)
) AS angles(position, angle_begin, angle_end);
INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end)
SELECT satellite_id, position, angle_begin, angle_end
FROM (VALUES
(7),
(8),
(9),
(10),
(11),
(12),
(13),
(14),
(15),
(16),
(17),
(18),
(19),
(20),
(21)
) AS satellites(satellite_id)
CROSS JOIN (VALUES
(1, 25.00, 23.70),
(2, 23.70, 22.40),
(3, 22.40, 21.10),
(4, 21.10, 19.80),
(5, 19.80, 18.50),
(6, 18.50, 17.20),
(7, 17.20, 15.90),
(8, 15.90, 14.60),
(9, 14.60, 13.30),
(10, 13.30, 12.00),
(11, 12.00, 10.70),
(12, 10.70, 9.40),
(13, 9.40, 8.10),
(14, 8.10, 6.80),
(15, 6.80, 5.50),
(16, 5.50, 4.20),
(17, 4.20, 2.90),
(18, 2.90, 1.60),
(19, 1.60, 0.30),
(20, 0.30, -1.00),
(21, -1.00, -2.30),
(22, -2.30, -3.60),
(23, -3.60, -4.90),
(24, -4.90, -6.20),
(25, -6.20, -7.50),
(26, -7.50, -8.80),
(27, -8.80, -10.10),
(28, -10.10, -11.40),
(29, -11.40, -12.70),
(30, -12.70, -14.00),
(31, -14.00, -15.30),
(32, -15.30, -16.60),
(33, -16.60, -17.90),
(34, -17.90, -19.20),
(35, -19.20, -20.50),
(36, -20.50, -21.80),
(37, -21.80, -23.10),
(38, -23.10, -24.40),
(39, -24.40, -25.70)
) AS angles(position, angle_begin, angle_end);
INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end)
SELECT satellite_id, position, angle_begin, angle_end
FROM (VALUES
(666),
(667),
(668),
(669)
) AS satellites(satellite_id)
CROSS JOIN (VALUES
(40, 30.00, 28.70),
(41, 28.70, 27.40),
(42, 27.40, 26.10),
(43, 26.10, 24.80),
(44, 24.80, 23.50),
(45, 23.50, 22.20),
(46, 22.20, 20.90),
(47, 20.90, 19.60),
(48, 19.60, 18.30),
(49, 18.30, 17.00),
(50, 17.00, 15.70),
(51, 15.70, 14.40),
(52, 14.40, 13.10),
(53, 13.10, 11.80),
(54, 11.80, 10.50),
(55, 10.50, 9.20),
(56, 9.20, 7.90),
(57, 7.90, 6.60),
(58, 6.60, 5.30),
(59, 5.30, 4.00),
(60, 4.00, 2.70),
(61, 2.70, 1.40),
(62, 1.40, 0.10),
(63, 0.10, -1.20),
(64, -1.20, -2.50),
(65, -2.50, -3.80),
(66, -3.80, -5.10),
(67, -5.10, -6.40),
(68, -6.40, -7.70),
(69, -7.70, -9.00),
(70, -9.00, -10.30),
(71, -10.30, -11.60),
(72, -11.60, -12.90),
(73, -12.90, -14.20),
(74, -14.20, -15.50),
(75, -15.50, -16.80),
(76, -16.80, -18.10),
(77, -18.10, -19.40),
(78, -19.40, -20.70),
(79, -20.70, -22.00),
(80, -22.00, -23.30),
(81, -23.30, -24.60),
(82, -24.60, -25.90),
(83, -25.90, -27.20),
(84, -27.20, -28.50),
(85, -28.50, -29.80),
(86, -29.80, -31.10)
) AS angles(position, angle_begin, angle_end);
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS satellite_group (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(128) NOT NULL
);
CREATE TABLE IF NOT EXISTS satellite_group_satellite (
group_id BIGINT NOT NULL REFERENCES satellite_group(id) ON DELETE CASCADE,
satellite_id BIGINT NOT NULL REFERENCES satellite(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, satellite_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_satellite_group_name ON satellite_group(name);
CREATE INDEX IF NOT EXISTS idx_satellite_group_satellite_satellite_id
ON satellite_group_satellite(satellite_id);
INSERT INTO satellite_group (name)
SELECT 'KONDOR'
WHERE NOT EXISTS (
SELECT 1
FROM satellite_group
WHERE name = 'KONDOR'
);
INSERT INTO satellite_group_satellite (group_id, satellite_id)
SELECT satellite_group.id, satellites.satellite_id
FROM satellite_group
CROSS JOIN (
VALUES
(56756),
(62138)
) AS satellites(satellite_id)
WHERE satellite_group.name = 'KONDOR'
AND NOT EXISTS (
SELECT 1
FROM satellite_group_satellite
WHERE satellite_group_satellite.group_id = satellite_group.id
AND satellite_group_satellite.satellite_id = satellites.satellite_id
);
@@ -0,0 +1,2 @@
ALTER TABLE satellite_slot_profile
ADD COLUMN slot_duration BIGINT NOT NULL DEFAULT 10;
@@ -0,0 +1,324 @@
package space.nstart.pcp.pcp_satellite_catalog_service.controller
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatusCode
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupUpdateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SatelliteCatalogControllerTest {
@LocalServerPort
private var port: Int = 0
@Test
fun `catalog returns seeded summaries`() {
val response = client()
.get()
.uri("/api/satellites")
.retrieve()
.bodyToFlux(SatelliteSummaryDTO::class.java)
.collectList()
.block()
.orEmpty()
assertEquals(28, response.size)
assertEquals("KONDOR-FKA NO. 1", response.first { it.id == 56756L }.name)
assertEquals(true, response.first { it.id == 62138L }.scanTle)
}
@Test
fun `catalog returns full satellite card`() {
val response = client()
.get()
.uri("/api/satellites/22")
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.block()!!
assertEquals(22L, response.id)
assertEquals("EMISSIO-22", response.code)
assertEquals("Emissio", response.name)
assertEquals(20, response.slotProfile?.defaultAngles?.size)
assertEquals(18.5, response.slotProfile?.defaultAngles?.first()?.angleBegin)
assertEquals(21.58, response.slotProfile?.defaultAngles?.first()?.angleEnd)
assertEquals(10L, response.slotProfile?.slotDuration)
assertEquals(300L, response.observationProfile?.durationMaxSeconds)
}
@Test
fun `catalog returns profiles, batch and seeded groups`() {
val observationProfile = client()
.get()
.uri("/api/satellites/56756/observation-profile")
.retrieve()
.bodyToMono(SatelliteObservationProfileDTO::class.java)
.block()!!
val slotProfile = client()
.get()
.uri("/api/satellites/56756/slot-profile")
.retrieve()
.bodyToMono(SatelliteSlotProfileDTO::class.java)
.block()!!
val batch = client()
.post()
.uri("/api/satellites/batch")
.bodyValue(SatelliteBatchRequestDTO(ids = listOf(56756L, 62138L)))
.retrieve()
.bodyToFlux(SatelliteDTO::class.java)
.collectList()
.block()
.orEmpty()
val groups = client()
.get()
.uri("/api/satellite-groups")
.retrieve()
.bodyToFlux(SatelliteGroupDTO::class.java)
.collectList()
.block()
.orEmpty()
assertEquals(1.5, observationProfile.captureAngle)
assertNotNull(slotProfile.tnCalc)
assertEquals(10L, slotProfile.slotDuration)
assertEquals(10, slotProfile.defaultAngles.size)
assertEquals(20.0, slotProfile.defaultAngles.first().angleBegin)
assertEquals(23.0, slotProfile.defaultAngles.first().angleEnd)
assertEquals(300L, observationProfile.durationMaxSeconds)
assertEquals(listOf(56756L, 62138L), batch.map { it.id })
assertEquals(listOf(56756L, 62138L), groups.first { it.name == "KONDOR" }.satelliteIds)
}
@Test
fun `catalog supports satellite crud`() {
val satelliteId = 70001L
val created = client()
.post()
.uri("/api/satellites")
.bodyValue(
SatelliteCreateDTO(
id = satelliteId,
noradId = 90001L,
code = "TEST-SAT-70001",
name = "TestSat",
typeCode = "TEST",
active = true,
scanTle = true,
visualization = SatelliteVisualizationDTO(red = 10, green = 20, blue = 30)
)
)
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.block()!!
val updated = client()
.put()
.uri("/api/satellites/$satelliteId")
.bodyValue(
SatelliteUpdateDTO(
noradId = 90002L,
code = "TEST-SAT-70001-UPD",
name = "TestSatUpdated",
typeCode = "TEST-UPD",
active = false,
scanTle = false,
visualization = SatelliteVisualizationDTO(red = 40, green = 50, blue = 60)
)
)
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.block()!!
delete("/api/satellites/$satelliteId")
assertEquals(satelliteId, created.id)
assertEquals("TEST-SAT-70001", created.code)
assertEquals(90002L, updated.noradId)
assertEquals("TestSatUpdated", updated.name)
assertEquals(40, updated.visualization.red.toInt())
assertEquals(400, status("GET", "/api/satellites/$satelliteId").value())
}
@Test
fun `catalog supports profile crud`() {
val satelliteId = 70002L
client()
.post()
.uri("/api/satellites")
.bodyValue(
SatelliteCreateDTO(
id = satelliteId,
code = "TEST-SAT-70002",
name = "ProfileSat",
typeCode = "TEST",
visualization = SatelliteVisualizationDTO(red = 1, green = 2, blue = 3)
)
)
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.block()!!
val createdObservation = client()
.post()
.uri("/api/satellites/$satelliteId/observation-profile")
.bodyValue(
SatelliteObservationProfileDTO(
captureAngle = 2.5,
sunAngleMin = -15.0,
durationMinSeconds = 12,
durationMaxSeconds = 120,
mmiSeconds = 15,
dailyMaxDurationSeconds = 1800,
revolutionMaxDurationSeconds = 180
)
)
.retrieve()
.bodyToMono(SatelliteObservationProfileDTO::class.java)
.block()!!
val updatedObservation = client()
.put()
.uri("/api/satellites/$satelliteId/observation-profile")
.bodyValue(createdObservation.copy(durationMaxSeconds = 240))
.retrieve()
.bodyToMono(SatelliteObservationProfileDTO::class.java)
.block()!!
val createdSlot = client()
.post()
.uri("/api/satellites/$satelliteId/slot-profile")
.bodyValue(
SatelliteSlotProfileDTO(
cycleRevs = 10,
slotDuration = 15,
durationCalcDays = 5,
maxChainLength = 2,
defaultAngles = listOf(
SatelliteSlotAngleDTO(angleBegin = 10.0, angleEnd = 20.0),
SatelliteSlotAngleDTO(angleBegin = 20.0, angleEnd = 30.0)
)
)
)
.retrieve()
.bodyToMono(SatelliteSlotProfileDTO::class.java)
.block()!!
val updatedSlot = client()
.put()
.uri("/api/satellites/$satelliteId/slot-profile")
.bodyValue(
createdSlot.copy(
cycleRevs = 11,
slotDuration = 17,
defaultAngles = listOf(SatelliteSlotAngleDTO(angleBegin = 30.0, angleEnd = 40.0))
)
)
.retrieve()
.bodyToMono(SatelliteSlotProfileDTO::class.java)
.block()!!
delete("/api/satellites/$satelliteId/observation-profile")
delete("/api/satellites/$satelliteId/slot-profile")
assertEquals(2.5, createdObservation.captureAngle)
assertEquals(240L, updatedObservation.durationMaxSeconds)
assertEquals(2, createdSlot.defaultAngles.size)
assertEquals(15L, createdSlot.slotDuration)
assertEquals(11L, updatedSlot.cycleRevs)
assertEquals(17L, updatedSlot.slotDuration)
assertEquals(30.0, updatedSlot.defaultAngles.single().angleBegin)
assertEquals(400, status("GET", "/api/satellites/$satelliteId/observation-profile").value())
assertEquals(400, status("GET", "/api/satellites/$satelliteId/slot-profile").value())
delete("/api/satellites/$satelliteId")
}
@Test
fun `catalog supports group crud`() {
val satelliteId = 70003L
client()
.post()
.uri("/api/satellites")
.bodyValue(
SatelliteCreateDTO(
id = satelliteId,
code = "TEST-SAT-70003",
name = "GroupSat",
typeCode = "TEST",
visualization = SatelliteVisualizationDTO(red = 7, green = 8, blue = 9)
)
)
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.block()!!
val createdGroup = client()
.post()
.uri("/api/satellite-groups")
.bodyValue(
SatelliteGroupCreateDTO(
name = "TEST-GROUP",
satelliteIds = listOf(satelliteId, 56756L)
)
)
.retrieve()
.bodyToMono(SatelliteGroupDTO::class.java)
.block()!!
val updatedGroup = client()
.put()
.uri("/api/satellite-groups/${createdGroup.id}")
.bodyValue(
SatelliteGroupUpdateDTO(
name = "TEST-GROUP-UPD",
satelliteIds = listOf(satelliteId)
)
)
.retrieve()
.bodyToMono(SatelliteGroupDTO::class.java)
.block()!!
delete("/api/satellite-groups/${createdGroup.id}")
delete("/api/satellites/$satelliteId")
assertEquals("TEST-GROUP", createdGroup.name)
assertEquals(listOf(56756L, satelliteId), createdGroup.satelliteIds)
assertEquals("TEST-GROUP-UPD", updatedGroup.name)
assertEquals(listOf(satelliteId), updatedGroup.satelliteIds)
assertEquals(400, status("GET", "/api/satellite-groups/${createdGroup.id}").value())
}
private fun delete(path: String) {
client()
.delete()
.uri(path)
.retrieve()
.toBodilessEntity()
.block()
}
private fun status(method: String, path: String): HttpStatusCode =
client()
.method(org.springframework.http.HttpMethod.valueOf(method))
.uri(path)
.exchangeToMono { response -> Mono.just(response.statusCode()) }
.block()!!
private fun client(): WebClient = WebClient.create("http://localhost:$port")
}
@@ -0,0 +1,20 @@
spring:
config:
import: "optional:configserver:"
cloud:
config:
enabled: false
datasource:
url: jdbc:h2:mem:pcp_satellite_catalog;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
flyway:
enabled: true
locations: classpath:db/migration