This commit is contained in:
Дмитрий Соловьев
2026-05-29 13:35:32 +03:00
parent 3dce0aec0d
commit e7c2060e2e
40 changed files with 371 additions and 80 deletions
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
package space.nstart.pcp_tgu_service.config
/** Настраивает Jackson-сериализацию для payload'ов клиента Camunda. */
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
@@ -13,15 +14,23 @@ import org.springframework.context.annotation.Configuration
@Configuration
class CamundaJacksonConfig {
/** Создаёт JSON mapper для Camunda с поддержкой Kotlin-классов и Java time типов. */
@Bean("camundaJsonMapper")
fun camundaJsonMapper(): CamundaJsonMapper {
val objectMapper = JsonMapper.builder()
/**
* The service code still depends on Jackson 2 APIs, while the shared Boot 4 stack
* auto-configures Jackson 3 for WebFlux. Keep a dedicated Jackson 2 mapper for
* internal parsing and Camunda payloads until the module is migrated.
*/
@Bean
fun objectMapper(): ObjectMapper {
return JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.addModule(JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()
}
/** Создаёт JSON mapper для Camunda с поддержкой Kotlin-классов и Java time типов. */
@Bean("camundaJsonMapper")
fun camundaJsonMapper(objectMapper: ObjectMapper): CamundaJsonMapper {
return CamundaObjectMapper(objectMapper)
}
}
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.dto.PlanResponse
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
@@ -19,8 +20,12 @@ import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
import space.nstart.pcp_tgu_service.service.PlanQueryService
import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService
import space.nstart.pcp_tgu_service.service.PlatformService
import space.nstart.pcp_tgu_service.service.RebuildPlanScheduleUseCase
import space.nstart.pcp_tgu_service.service.TestPlanDecisionService
import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService
import java.nio.file.Files
import java.nio.file.Path
import java.time.LocalDateTime
import java.util.UUID
@@ -36,7 +41,9 @@ class TestTguController(
private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase,
private val issueDuePlanWorker: IssueDuePlanWorker,
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
private val testPlanDecisionService: TestPlanDecisionService,
private val externalPointsClient: ExternalPointsClient,
private val platformService: PlatformService,
private val planQueryService: PlanQueryService
) {
/** Ставит refresh job для КА. */
@@ -71,6 +78,30 @@ class TestTguController(
handlePlanDecisionUseCase.handle(message)
}.subscribeOn(Schedulers.boundedElastic())
/** Генерирует ACCEPTED/REJECTED decision event для active attempt указанного persisted plan. */
@PostMapping("/plans/{planId}/decision")
fun generatePlanDecision(
@PathVariable planId: UUID,
@RequestParam decision: PlanDecision,
@RequestParam(required = false) reason: String?
): Mono<PlanDecisionMessage> =
Mono.fromCallable {
testPlanDecisionService.generateDecision(planId, decision, reason)
}.subscribeOn(Schedulers.boundedElastic())
/** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */
@PostMapping("/platforms/load-from-file")
fun loadPlatformsFromFile(@RequestParam("path") filePath: String): Mono<TestPlatformsLoadResponse> =
Mono.fromCallable {
val path = Path.of(filePath).toAbsolutePath().normalize()
val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path))
TestPlatformsLoadResponse(
filePath = path.toString(),
loadedCount = platforms.size,
allowedCount = platformService.loadAllowedPlatforms().size
)
}.subscribeOn(Schedulers.boundedElastic())
/** Загружает raw payload окон видимости из баллистики для диагностики. */
@GetMapping("/external-points/{noradId}")
fun getExternalPoints(
@@ -93,3 +124,9 @@ class TestTguController(
PlanResponse.from(planQueryService.getByPlanId(planId))
}.subscribeOn(Schedulers.boundedElastic())
}
data class TestPlatformsLoadResponse(
val filePath: String,
val loadedCount: Int,
val allowedCount: Int
)
@@ -1,6 +1,7 @@
package space.nstart.pcp_tgu_service.service
/** Загружает метаданные платформ КА и применяет простую in-memory фильтрацию. */
import com.fasterxml.jackson.databind.ObjectMapper
import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
@@ -14,6 +15,7 @@ import java.time.Instant
class PlatformService(
private val platformsClassifierClient: PlatformsClassifierClient,
private val classifierProperties: ClassifierProperties,
private val objectMapper: ObjectMapper,
private val clock: Clock = Clock.systemUTC()
) {
private val log = LoggerFactory.getLogger(javaClass)
@@ -40,6 +42,13 @@ class PlatformService(
now.isBefore(platform.validTo)
}
/** Replaces the in-memory platform cache from a raw NSI response payload for test-only manual runs. */
fun loadPlatformsFromNsiPayload(payloadJson: String): List<PlatformCatalogItem> =
synchronized(cacheLock) {
val platforms = objectMapper.readValue(payloadJson, Array<NsiPlatformDto>::class.java).toList()
cachePlatforms(platforms, Instant.now(clock))
}
private fun loadCachedPlatforms(): List<PlatformCatalogItem> {
val now = Instant.now(clock)
cachedPlatforms?.takeIf { cache -> cache.isActual(now) }?.let { cache -> return cache.platforms }
@@ -54,7 +63,11 @@ class PlatformService(
}
private fun fetchAndCachePlatforms(now: Instant): List<PlatformCatalogItem> {
val platforms = platformsClassifierClient.fetchPlatforms()
return cachePlatforms(platformsClassifierClient.fetchPlatforms(), now)
}
private fun cachePlatforms(platformDtos: List<NsiPlatformDto>, now: Instant): List<PlatformCatalogItem> {
val platforms = platformDtos
.mapNotNull { platform -> platform.toCatalogItem() }
cachedPlatforms = PlatformsCache(
@@ -0,0 +1,80 @@
package space.nstart.pcp_tgu_service.service
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime
import java.util.UUID
/**
* Test-only helper: генерирует decision event для текущей active attempt persisted plan.
*/
@ConditionalOnProperty(prefix = "tgu.test-controller", name = ["enabled"], havingValue = "true")
@Service
class TestPlanDecisionService(
private val plannedPlanRepository: PlannedPlanRepository,
private val planIssueAttemptRepository: PlanIssueAttemptRepository,
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase
) {
/**
* Создаёт synthetic decision event и проводит его через тот же use case, что Kafka consumer.
*/
fun generateDecision(planId: UUID, decision: PlanDecision, reason: String?): PlanDecisionMessage {
val plan = plannedPlanRepository.findById(planId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
val state = spacecraftPlanningStateJpaRepository.findById(plan.spacecraftId).orElse(null)
?: throw ResponseStatusException(
HttpStatus.CONFLICT,
"Planning state for spacecraftId=${plan.spacecraftId} was not found"
)
if (state.activePlanId != planId || state.activeAttemptId == null) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"Plan $planId is not the active plan waiting for decision"
)
}
val attempt = planIssueAttemptRepository.findById(state.activeAttemptId!!).orElse(null)
?: throw ResponseStatusException(
HttpStatus.CONFLICT,
"Active attempt ${state.activeAttemptId} for plan $planId was not found"
)
if (attempt.planId != planId) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"Active attempt ${attempt.attemptId} does not belong to plan $planId"
)
}
if (attempt.status !in ACTIVE_ATTEMPT_STATUSES) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"Attempt ${attempt.attemptId} is already terminal with status=${attempt.status}"
)
}
val message = PlanDecisionMessage(
eventId = UUID.randomUUID(),
spacecraftId = plan.spacecraftId,
planId = plan.planId,
attemptId = attempt.attemptId,
decision = decision,
decisionTime = LocalDateTime.now(),
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
)
handlePlanDecisionUseCase.handle(message)
return message
}
private companion object {
val ACTIVE_ATTEMPT_STATUSES = setOf(PlanIssueAttemptStatus.STARTING, PlanIssueAttemptStatus.STARTED)
const val DEFAULT_REASON = "TEST_CONTROLLER"
}
}
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
@@ -1,5 +1,6 @@
package space.nstart.pcp_tgu_service.integration.api
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -57,7 +58,8 @@ class ExternalPointsClientTest {
platformsUrl = "http://localhost",
platformsCacheTtl = java.time.Duration.ofMinutes(15),
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
)
),
jacksonObjectMapper().findAndRegisterModules()
) {
override fun loadPlatforms(status: String?): List<PlatformCatalogItem> =
error("ExternalPointsClient must use loadAllowedPlatforms")
@@ -1,5 +1,6 @@
package space.nstart.pcp_tgu_service.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
@@ -121,6 +122,41 @@ class PlatformServiceTest {
verify(client, times(1)).fetchPlatforms()
}
@Test
fun `loadPlatformsFromNsiPayload replaces cached platforms from file payload`() {
val client = mock(PlatformsClassifierClient::class.java)
`when`(client.fetchPlatforms()).thenReturn(
listOf(platformDto(id = "from-nsi-id", status = "OPERATIONAL", noradId = 1L))
)
val service = service(client)
val fromNsi = service.loadPlatforms()
val fromFile = service.loadPlatformsFromNsiPayload(
"""
[
{
"id": "from-file-id",
"businessKey": "business-from-file-id",
"data": {
"name": "Satellite from-file-id",
"status": "OPERATIONAL",
"norad_id": 25544,
"mission": "MISSION"
},
"validFrom": "2026-01-01T00:00:00Z",
"validTo": "2026-12-31T00:00:00Z"
}
]
""".trimIndent()
)
val cachedAfterFileLoad = service.loadPlatforms()
assertEquals(listOf("from-nsi-id"), fromNsi.map { it.nsiId })
assertEquals(listOf("from-file-id"), fromFile.map { it.nsiId })
assertEquals(listOf("from-file-id"), cachedAfterFileLoad.map { it.nsiId })
verify(client, times(1)).fetchPlatforms()
}
private fun service(
client: PlatformsClassifierClient,
clock: Clock = Clock.fixed(Instant.parse("2026-05-26T12:00:00Z"), ZoneOffset.UTC)
@@ -132,6 +168,7 @@ class PlatformServiceTest {
platformsCacheTtl = Duration.ofMinutes(15),
allowedPlatformStatuses = setOf("OPERATIONAL")
),
objectMapper = jacksonObjectMapper().findAndRegisterModules(),
clock = clock
)
@@ -0,0 +1,107 @@
package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
class TestPlanDecisionServiceTest {
@Test
fun `generate decision builds message for active attempt and delegates to use case`() {
val plan = plannedPlan()
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val state = SpacecraftPlanningStateEntity(
spacecraftId = plan.spacecraftId,
activePlanId = plan.planId,
activeAttemptId = attempt.attemptId
)
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
val message = service.generateDecision(plan.planId, PlanDecision.ACCEPTED, "manual accept")
assertNotNull(message.eventId)
assertEquals(plan.spacecraftId, message.spacecraftId)
assertEquals(plan.planId, message.planId)
assertEquals(attempt.attemptId, message.attemptId)
assertEquals(PlanDecision.ACCEPTED, message.decision)
assertEquals("manual accept", message.reason)
verify(handlePlanDecisionUseCase).handle(message)
}
@Test
fun `generate decision rejects plan without active attempt`() {
val plan = plannedPlan()
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val state = SpacecraftPlanningStateEntity(spacecraftId = plan.spacecraftId)
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
val exception = assertThrows(ResponseStatusException::class.java) {
service.generateDecision(plan.planId, PlanDecision.REJECTED, null)
}
assertEquals(HttpStatus.CONFLICT, exception.statusCode)
verifyNoInteractions(handlePlanDecisionUseCase)
}
private fun service(
plan: PlannedPlanEntity,
attempt: PlanIssueAttemptEntity,
state: SpacecraftPlanningStateEntity,
handlePlanDecisionUseCase: HandlePlanDecisionUseCase
): TestPlanDecisionService {
val plannedPlanRepository = mock(PlannedPlanRepository::class.java)
val planIssueAttemptRepository = mock(PlanIssueAttemptRepository::class.java)
val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java)
`when`(plannedPlanRepository.findById(plan.planId)).thenReturn(Optional.of(plan))
`when`(planIssueAttemptRepository.findById(attempt.attemptId)).thenReturn(Optional.of(attempt))
`when`(stateRepository.findById(plan.spacecraftId)).thenReturn(Optional.of(state))
return TestPlanDecisionService(
plannedPlanRepository = plannedPlanRepository,
planIssueAttemptRepository = planIssueAttemptRepository,
spacecraftPlanningStateJpaRepository = stateRepository,
handlePlanDecisionUseCase = handlePlanDecisionUseCase
)
}
private fun plannedPlan(): PlannedPlanEntity =
PlannedPlanEntity(
planId = UUID.randomUUID(),
spacecraftId = "25544",
startTime = LocalDateTime.now().plusHours(1),
endTime = LocalDateTime.now().plusHours(2),
kppId = "KPP-1",
status = PlannedPlanStatus.WAITING_DECISION
)
private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity =
PlanIssueAttemptEntity(
attemptId = UUID.randomUUID(),
planId = planId,
attemptNo = 1,
status = status
)
}