Формат времени в UTC

This commit is contained in:
emelianov
2026-06-25 16:21:17 +03:00
parent d0ec0ef754
commit fe54c095ab
164 changed files with 1847 additions and 634 deletions
@@ -1,15 +1,24 @@
package space.nstart.pcp_tgu_service.config
/** Настраивает Jackson-сериализацию для payload'ов клиента Camunda. */
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.KotlinModule
import io.camunda.client.api.JsonMapper as CamundaJsonMapper
import io.camunda.client.impl.CamundaObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
import java.time.LocalDateTime
@Configuration
class CamundaJacksonConfig {
@@ -24,6 +33,7 @@ class CamundaJacksonConfig {
return JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.addModule(JavaTimeModule())
.addModule(jackson2UtcLocalDateTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()
}
@@ -34,3 +44,21 @@ class CamundaJacksonConfig {
return CamundaObjectMapper(objectMapper)
}
}
private fun jackson2UtcLocalDateTimeModule(): SimpleModule = SimpleModule("pcp-tgu-service-jackson2-utc-local-date-time")
.addSerializer(LocalDateTime::class.java, Jackson2UtcLocalDateTimeSerializer())
.addDeserializer(LocalDateTime::class.java, Jackson2UtcLocalDateTimeDeserializer())
private class Jackson2UtcLocalDateTimeSerializer : JsonSerializer<LocalDateTime>() {
override fun serialize(value: LocalDateTime, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(UtcDateTimes.format(value))
}
}
private class Jackson2UtcLocalDateTimeDeserializer : JsonDeserializer<LocalDateTime>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime {
val value = p.valueAsString ?: throw IllegalArgumentException("Expected ISO-8601 UTC date-time string")
return UtcDateTimes.parse(value)
}
}
@@ -0,0 +1,68 @@
package space.nstart.pcp_tgu_service.config
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.format.FormatterRegistry
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
import tools.jackson.core.JsonGenerator
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.SerializationContext
import tools.jackson.databind.deser.std.StdDeserializer
import tools.jackson.databind.module.SimpleModule
import tools.jackson.databind.ser.std.StdSerializer
import java.time.Clock
import java.time.LocalDateTime
/**
* UTC contract for REST JSON and query parameters.
*
* Domain/JPA models still keep LocalDateTime as UTC-naive values. External API accepts explicit
* UTC/offset strings and legacy naive strings, normalizes offsets to UTC, and writes responses
* as ISO-8601 UTC strings with Z.
*/
@Configuration
class UtcTimeConfiguration : WebMvcConfigurer {
@Bean
fun clock(): Clock = Clock.systemUTC()
@Bean
@ConditionalOnMissingBean(WebClient.Builder::class)
fun webClientBuilder(): WebClient.Builder = WebClient.builder()
@Bean
fun tguUtcLocalDateTimeCustomizer(): JsonMapperBuilderCustomizer = JsonMapperBuilderCustomizer { builder ->
builder.addModule(
SimpleModule("pcp-tgu-service-utc-local-date-time")
.addSerializer(LocalDateTime::class.java, UtcLocalDateTimeSerializer())
.addDeserializer(LocalDateTime::class.java, UtcLocalDateTimeDeserializer())
)
}
override fun addFormatters(registry: FormatterRegistry) {
registry.addConverter(UtcLocalDateTimeQueryParamConverter())
}
}
private class UtcLocalDateTimeQueryParamConverter : Converter<String, LocalDateTime> {
override fun convert(source: String): LocalDateTime = UtcDateTimes.parse(source)
}
private class UtcLocalDateTimeSerializer : StdSerializer<LocalDateTime>(LocalDateTime::class.java) {
override fun serialize(value: LocalDateTime, gen: JsonGenerator, ctxt: SerializationContext) {
gen.writeString(UtcDateTimes.format(value))
}
}
private class UtcLocalDateTimeDeserializer : StdDeserializer<LocalDateTime>(LocalDateTime::class.java) {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime {
val value = p.valueAsString ?: throw IllegalArgumentException("Expected ISO-8601 UTC date-time string")
return UtcDateTimes.parse(value)
}
}
@@ -1,7 +1,6 @@
package space.nstart.pcp_tgu_service.controller
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -90,12 +89,8 @@ class TestTguController(
@GetMapping("/external-points/{noradId}")
fun getExternalPoints(
@PathVariable noradId: Long,
@RequestParam("time_start")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
timeStart: LocalDateTime,
@RequestParam("time_stop")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
timeStop: LocalDateTime
@RequestParam("time_start") timeStart: LocalDateTime,
@RequestParam("time_stop") timeStop: LocalDateTime
): String =
externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop)
@@ -10,8 +10,8 @@ import java.util.UUID
/**
* DTO ответа REST API для плана.
*
* Времена ([startTime]/[endTime]) — naive `LocalDateTime`, сериализуются как ISO-строка без зоны
* (напр. `"2026-05-29T01:00:00"`). Хранение, домен и API работают на одних настенных часах.
* Времена ([startTime]/[endTime]) — UTC-naive `LocalDateTime` в домене/БД.
* На REST-границе сериализуются как ISO-8601 UTC с `Z` (например `"2026-05-29T01:00:00Z"`).
*/
data class PlanResponse(
@@ -21,10 +21,10 @@ data class PlanResponse(
/** Идентификатор космического аппарата */
val spacecraftId: String,
/** Время начала плана (ISO без зоны). */
/** Время начала плана (UTC). */
val startTime: LocalDateTime,
/** Время окончания плана (ISO без зоны). */
/** Время окончания плана (UTC). */
val endTime: LocalDateTime,
/** Идентификатор пункта закладки */
@@ -8,6 +8,7 @@ import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Persisted job на запуск короткого BPMN-процесса обновления слотов accepted plan.
@@ -39,10 +40,10 @@ class AcceptedPlanSlotsUpdateJobEntity(
var lastError: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = UtcDateTimes.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(planId = UUID.randomUUID())
@@ -8,6 +8,7 @@ import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Одна попытка выдачи persisted plan через один BPMN process instance.
@@ -45,10 +46,10 @@ class PlanIssueAttemptEntity(
var finishedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = UtcDateTimes.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0)
@@ -8,6 +8,7 @@ import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Persisted business-plan с устойчивым planId по unique business key.
@@ -56,10 +57,10 @@ class PlannedPlanEntity(
var dismissedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = UtcDateTimes.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(
@@ -6,6 +6,7 @@ import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Dedup-запись Kafka decision event по eventId.
@@ -18,7 +19,7 @@ class ProcessedPlanDecisionEventEntity(
var eventId: UUID,
@Column(name = "received_at", nullable = false)
var receivedAt: LocalDateTime = LocalDateTime.now()
var receivedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(eventId = UUID.randomUUID())
@@ -7,6 +7,7 @@ import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
@Entity
@Table(name = "spacecraft_planning_state")
@@ -42,7 +43,7 @@ class SpacecraftPlanningStateEntity(
var pausedAfterReject: Boolean = false,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "")
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Persisted snapshot КА из НСИ, используемый как вход для refresh jobs.
@@ -44,10 +45,10 @@ class SpacecraftSnapshotEntity(
var active: Boolean = true,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = UtcDateTimes.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "", noradId = 0)
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Полный raw snapshot окон видимости КА из баллистики.
@@ -40,10 +41,10 @@ class SpacecraftVisibilitySnapshotEntity(
var windowsCount: Int? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = UtcDateTimes.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "", noradId = 0, payloadJson = "", payloadHash = "", fetchedAt = LocalDateTime.MIN)
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Job на перечитывание окон видимости одного КА у баллистики.
@@ -41,10 +42,10 @@ class VisibilityRefreshJobEntity(
var rerunRequested: Boolean = false,
@Column(name = "requested_at", nullable = false)
var requestedAt: LocalDateTime = LocalDateTime.now(),
var requestedAt: LocalDateTime = UtcDateTimes.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = UtcDateTimes.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "")
@@ -13,6 +13,7 @@ import org.springframework.web.reactive.function.client.WebClientException
import org.springframework.web.reactive.function.client.WebClientResponseException
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Клиент внешнего API с окнами видимости КА.
@@ -20,6 +21,7 @@ import java.time.temporal.ChronoUnit
@Component
class ExternalPointsClient(
externalApiProperties: ExternalApiProperties,
webClientBuilder: WebClient.Builder,
private val platformService: PlatformService,
private val planningProperties: PlanningProperties,
private val visibilityPayloadParser: VisibilityPayloadParser
@@ -27,7 +29,7 @@ class ExternalPointsClient(
private val log = LoggerFactory.getLogger(javaClass)
private val webClient: WebClient = WebClient.builder()
private val webClient: WebClient = webClientBuilder
.baseUrl(externalApiProperties.baseUrl)
.build()
@@ -36,7 +38,7 @@ class ExternalPointsClient(
* которые разрешены для работы TGU.
*/
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> {
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStart = UtcDateTimes.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
return platformService.loadAllowedPlatforms()
@@ -93,8 +95,8 @@ class ExternalPointsClient(
.uri { uriBuilder ->
uriBuilder
.path("/api/satellites/{satelliteId}/rva")
.queryParam("time_start", timeStart)
.queryParam("time_stop", timeStop)
.queryParam("time_start", UtcDateTimes.format(timeStart))
.queryParam("time_stop", UtcDateTimes.format(timeStop))
.build(satelliteId)
}
.retrieve()
@@ -4,6 +4,7 @@ import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
import space.nstart.pcp_tgu_service.config.MissionPlaningProperties
import java.time.Duration
import java.time.LocalDateTime
@@ -23,10 +24,25 @@ data class MissionBlankRequest(
val missionStop: LocalDateTime
)
@Component
class MissionPlaningClient(properties: MissionPlaningProperties) {
private data class MissionCloneOutboundRequest(
val mode: String,
val windowStart: String? = null,
val windowStop: String? = null
)
private val webClient: WebClient = WebClient.builder()
private data class MissionBlankOutboundRequest(
val satelliteId: Long,
val missionStart: String,
val missionStop: String
)
@Component
class MissionPlaningClient(
properties: MissionPlaningProperties,
webClientBuilder: WebClient.Builder
) {
private val webClient: WebClient = webClientBuilder
.baseUrl(properties.baseUrl)
.build()
@@ -35,7 +51,7 @@ class MissionPlaningClient(properties: MissionPlaningProperties) {
fun cloneMission(missionId: UUID, req: MissionCloneRequest): MissionCloneResponse =
webClient.post()
.uri("/api/missions/{id}/clone", missionId)
.bodyValue(req)
.bodyValue(req.toOutbound())
.retrieve()
.onStatus({ it.isError }) { resp ->
resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body ->
@@ -53,11 +69,23 @@ class MissionPlaningClient(properties: MissionPlaningProperties) {
"Пустой ответ mission-planing на клон миссии $missionId"
)
private fun MissionCloneRequest.toOutbound(): MissionCloneOutboundRequest = MissionCloneOutboundRequest(
mode = mode,
windowStart = windowStart?.let(UtcDateTimes::format),
windowStop = windowStop?.let(UtcDateTimes::format)
)
private fun MissionBlankRequest.toOutbound(): MissionBlankOutboundRequest = MissionBlankOutboundRequest(
satelliteId = satelliteId,
missionStart = UtcDateTimes.format(missionStart),
missionStop = UtcDateTimes.format(missionStop)
)
/** Создаёт пустую миссию на окно (план новой цепочки «без истории»). */
fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse =
webClient.post()
.uri("/api/missions/blank")
.bodyValue(req)
.bodyValue(req.toOutbound())
.retrieve()
.onStatus({ it.isError }) { resp ->
resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body ->
@@ -6,10 +6,8 @@ import space.nstart.pcp_tgu_service.domain.InsertionPoint
import space.nstart.pcp_tgu_service.domain.ObservationWindow
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto
import java.time.Instant
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
/**
@@ -43,21 +41,11 @@ class VisibilityPayloadParser(
}
/**
* Разбирает ISO-строку времени баллистики в naive `LocalDateTime`.
* Разбирает ISO-строку времени баллистики в UTC-naive `LocalDateTime`.
*
* Если время приходит с зоной (`...Z`/офсет), момент проецируем в локальный пояс JVM
* (`ZoneId.systemDefault()`): naive-время в системе — настенные часы локальной зоны, в которой
* работают все сервисы, без UTC-семантики. Naive-строку без зоны берём как есть.
*
* Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time`
* хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется.
* Без обрезки проекция (наносекунды) и persisted-планы (микросекунды) перестают совпадать по
* бизнес-ключу — ломается дедуп в read-пути и матчинг базы в [calculatePlansAfter].
* Явные UTC/offset-строки нормализуются в UTC, legacy naive-строки считаются UTC-naive.
* Обрезаем до микросекунд — это эффективная точность persisted-планов в БД.
*/
private fun toLocalDateTime(value: String): LocalDateTime =
try {
LocalDateTime.ofInstant(Instant.parse(value), ZoneId.systemDefault())
} catch (_: DateTimeParseException) {
LocalDateTime.parse(value)
}.truncatedTo(ChronoUnit.MICROS)
UtcDateTimes.parse(value).truncatedTo(ChronoUnit.MICROS)
}
@@ -5,6 +5,7 @@ import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Создаёт persisted job обновления слотов для accepted plan.
@@ -16,6 +17,6 @@ class AcceptedPlanSlotsUpdateJobService(
/** Idempotent enqueue: один planId получает максимум одну job. */
@Transactional
fun enqueue(planId: UUID) {
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now())
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, UtcDateTimes.now())
}
}
@@ -11,6 +11,7 @@ import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobReposit
import java.net.InetAddress
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Scale-out safe worker, который запускает updateAcceptedPlanSlots для accepted plan.
@@ -35,7 +36,7 @@ class AcceptedPlanSlotsUpdateWorker(
/** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */
fun claimJobs(limit: Int): List<AcceptedPlanSlotsUpdateJobEntity> =
transactionTemplate.execute {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit)
jobs.forEach { job ->
job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING
@@ -69,7 +70,7 @@ class AcceptedPlanSlotsUpdateWorker(
private fun markDone(planId: UUID, processInstanceKey: Long) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
job.status = AcceptedPlanSlotsUpdateJobStatus.DONE
job.processInstanceKey = processInstanceKey
@@ -83,7 +84,7 @@ class AcceptedPlanSlotsUpdateWorker(
private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS
job.processInstanceKey = processInstanceKey ?: job.processInstanceKey
@@ -18,6 +18,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Создаёт план новой цепочки (вкладка «Планы», действие оператора «Создать план») и сразу считает
@@ -46,7 +47,7 @@ class CreateChainPlanUseCase(
private val log = LoggerFactory.getLogger(javaClass)
fun createChainPlan(req: CreateChainPlanRequest): CreateChainPlanResponse {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
if (!req.windowStop.isAfter(req.windowStart)) {
throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "windowStop должен быть позже windowStart")
@@ -16,6 +16,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Создаёт черновик плана (DRAFT) на основе существующего базового плана.
@@ -45,7 +46,7 @@ class CreateDraftUseCase(
val base = plannedPlanRepository.findById(basePlanId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $basePlanId not found") }
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
return when (req.mode) {
DraftMode.REPLACE -> createReplaceDraft(base, now)
@@ -9,6 +9,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* «Снять с учёта» / «Вернуть на учёт» терминально-негативный план.
@@ -44,8 +45,8 @@ class DismissPlanUseCase(
log.info("План {} уже снят с учёта ({}) — идемпотентно", planId, plan.dismissedAt)
return
}
plan.dismissedAt = LocalDateTime.now()
plan.updatedAt = LocalDateTime.now()
plan.dismissedAt = UtcDateTimes.now()
plan.updatedAt = UtcDateTimes.now()
plannedPlanRepository.save(plan)
log.info("План {} (status={}) снят с учёта", planId, plan.status)
}
@@ -59,7 +60,7 @@ class DismissPlanUseCase(
return
}
plan.dismissedAt = null
plan.updatedAt = LocalDateTime.now()
plan.updatedAt = UtcDateTimes.now()
plannedPlanRepository.save(plan)
log.info("План {} (status={}) возвращён на учёт", planId, plan.status)
}
@@ -20,6 +20,7 @@ import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventReposit
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.Duration
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
@@ -132,7 +133,7 @@ class HandlePlanDecisionUseCase(
)
}
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
plan.status = PlannedPlanStatus.LAID_IN
plan.updatedAt = now
@@ -213,7 +214,7 @@ class HandlePlanDecisionUseCase(
return
}
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
plan.status = terminalStatus
plan.updatedAt = now
plannedPlanRepository.save(plan)
@@ -225,7 +226,7 @@ class HandlePlanDecisionUseCase(
message: PlanDecisionMessage,
attempt: PlanIssueAttemptEntity
) {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
attempt.status = when (message.decision) {
PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED
PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED
@@ -241,7 +242,7 @@ class HandlePlanDecisionUseCase(
attempt: PlanIssueAttemptEntity,
plan: PlannedPlanEntity
) {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.AWAITING_LAYIN
@@ -262,7 +263,7 @@ class HandlePlanDecisionUseCase(
plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity
) {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.REJECTED
@@ -307,7 +308,7 @@ class HandlePlanDecisionUseCase(
}
private fun claimEvent(eventId: java.util.UUID): Boolean =
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0
processedPlanDecisionEventRepository.insertIfAbsent(eventId, UtcDateTimes.now()) != 0
private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity {
spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
@@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА.
@@ -25,7 +26,7 @@ class HandleStationParamsChangedUseCase(
/** Идемпотентно по eventId; ставит refresh job по всем активным КА. */
@Transactional
fun handle(message: StationParamsChangedMessage) {
val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0
val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, UtcDateTimes.now()) != 0
if (!claimed) {
return
}
@@ -20,6 +20,7 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaReposit
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Выдаёт дозревшие планы. В on-demand-модели голова цепочки считается из снапшота ЗРВ
@@ -83,7 +84,7 @@ class IssueDuePlanWorker(
/** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val state = getOrCreateLockedState(spacecraftId)
// Форвард-цепочку не выдаём, пока по КА есть выдача «в работе»: ISSUING или WAITING_DECISION
// в пределах grace после старта. Прошедший grace больше не блокирует — молчаливо продолжаем
@@ -154,7 +155,7 @@ class IssueDuePlanWorker(
.maxByOrNull { plan -> plan.endTime }?.planId ?: fallback
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
val state = getOrCreateLockedState(plan.spacecraftId)
assertReissuableInSlot(plan, now)
@@ -176,7 +177,7 @@ class IssueDuePlanWorker(
}
private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null
if (plan.status != PlannedPlanStatus.DRAFT) {
throw ResponseStatusException(
@@ -326,7 +327,7 @@ class IssueDuePlanWorker(
private fun markStarted(response: ProcessStartResponse) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult
@@ -350,7 +351,7 @@ class IssueDuePlanWorker(
private fun markAmbiguous(preparedAttempt: PreparedAttempt) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult
attempt.status = PlanIssueAttemptStatus.AMBIGUOUS
@@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Read-сервис планов: объединяет persisted (выданные, история — вечно) с вычисленной
@@ -80,7 +81,7 @@ class PlanQueryService(
.sortedBy { response -> response.startTime }
private fun historySince(historyDays: Long): LocalDateTime =
LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0))
UtcDateTimes.now().minusDays(historyDays.coerceAtLeast(0))
private fun PlannedPlanEntity.businessKey(): String =
"$spacecraftId|$startTime|$endTime|$kppId"
@@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser
import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository
import java.security.MessageDigest
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Сохраняет raw visibility payload. Расписание считается on-demand из снапшота
@@ -25,7 +26,7 @@ class SpacecraftVisibilitySnapshotService(
val payloadHash = sha256(payloadJson)
val existing = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null)
val metadata = runCatching { extractMetadata(spacecraftId, payloadJson) }.getOrNull()
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
if (existing == null) {
spacecraftVisibilitySnapshotRepository.save(
@@ -5,7 +5,7 @@ import org.springframework.transaction.support.TransactionTemplate
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneId
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs.
@@ -26,7 +26,7 @@ class SyncSpacecraftFromNsiUseCase(
}
private fun persistAllowedSpacecraft(allowedPlatforms: List<PlatformCatalogItem>) {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() }
allowedPlatforms.forEach { platform ->
@@ -40,10 +40,9 @@ class SyncSpacecraftFromNsiUseCase(
name = platform.name,
status = platform.status,
mission = platform.mission,
// validFrom/validTo приходят как Instant; в naive-поля БД пишем настенные часы локальной
// зоны JVM (ZoneId.systemDefault()) — naive-время в системе локальное, без UTC-семантики.
validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) },
validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) },
// validFrom/validTo приходят как Instant; в naive-поля БД пишем UTC-naive момент.
validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, UtcDateTimes.UTC) },
validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, UtcDateTimes.UTC) },
now = now
)
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC")
@@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import java.time.LocalDateTime
import java.util.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Test-only helper: генерирует decision event для последней (живой) attempt persisted plan.
@@ -50,7 +51,7 @@ class TestPlanDecisionService(
planId = plan.planId,
attemptId = attempt.attemptId,
decision = decision,
decisionTime = LocalDateTime.now(),
decisionTime = UtcDateTimes.now(),
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
)
handlePlanDecisionUseCase.handle(message)
@@ -70,7 +71,7 @@ class TestPlanDecisionService(
eventId = UUID.randomUUID(),
spacecraftId = plan.spacecraftId,
planId = plan.planId,
occurredAt = LocalDateTime.now(),
occurredAt = UtcDateTimes.now(),
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
)
handlePlanDecisionUseCase.handleLayinConfirmed(message)
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Coalesce-сервис для visibility_refresh_job по spacecraftId.
@@ -15,6 +16,6 @@ class VisibilityRefreshJobService(
/** Ставит или переоткрывает refresh job без дублей. */
@Transactional
fun enqueue(spacecraftId: String, noradId: Long?, reason: String) {
visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now())
visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, UtcDateTimes.now())
}
}
@@ -13,6 +13,7 @@ import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository
import java.net.InetAddress
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
/**
* Claim'ит visibility_refresh_job, читает raw payload у баллистики и сохраняет snapshot.
@@ -38,7 +39,7 @@ class VisibilityRefreshWorker(
private fun claimJobs(limit: Int): List<VisibilityRefreshJobEntity> =
transactionTemplate.execute {
val now = LocalDateTime.now()
val now = UtcDateTimes.now()
val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit)
jobs.forEach { job ->
job.status = JobStatus.RUNNING
@@ -61,10 +62,10 @@ class VisibilityRefreshWorker(
}
try {
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStart = UtcDateTimes.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
val payload = externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop)
spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now())
spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, UtcDateTimes.now())
markDone(spacecraftId)
} catch (ex: Exception) {
log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex)
@@ -79,7 +80,7 @@ class VisibilityRefreshWorker(
job.rerunRequested = false
job.lockedBy = null
job.lockedUntil = null
job.updatedAt = LocalDateTime.now()
job.updatedAt = UtcDateTimes.now()
visibilityRefreshJobRepository.save(job)
}
}
@@ -90,7 +91,7 @@ class VisibilityRefreshWorker(
job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING
job.lockedBy = null
job.lockedUntil = null
job.updatedAt = LocalDateTime.now()
job.updatedAt = UtcDateTimes.now()
visibilityRefreshJobRepository.save(job)
}
}
@@ -102,7 +103,7 @@ class VisibilityRefreshWorker(
job.reason = reason
job.lockedBy = null
job.lockedUntil = null
job.updatedAt = LocalDateTime.now()
job.updatedAt = UtcDateTimes.now()
visibilityRefreshJobRepository.save(job)
}
}
@@ -1,25 +1,37 @@
package space.nstart.pcp_tgu_service.dto
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
import java.time.LocalDateTime
import java.util.UUID
/**
* Контракт формата времени на проводе для [PlanResponse]: naive `LocalDateTime` → ISO без зоны
* (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам.
* Контракт формата времени на проводе для [PlanResponse]: UTC-naive `LocalDateTime` → ISO UTC с Z
* (напр. `"2026-05-29T01:00:00Z"`). Тест ловит случайный возврат к naive-строкам или числовым меткам.
*/
class PlanResponseContractTest {
private val mapper = jacksonObjectMapper().findAndRegisterModules()
.registerModule(
SimpleModule().addSerializer(LocalDateTime::class.java, object : JsonSerializer<LocalDateTime>() {
override fun serialize(value: LocalDateTime, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(UtcDateTimes.format(value))
}
})
)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes plan times as naive ISO without zone`() {
fun `serializes plan times as UTC ISO with Z`() {
val response = PlanResponse(
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
spacecraftId = "25544",
@@ -31,8 +43,8 @@ class PlanResponseContractTest {
val node = mapper.readTree(mapper.writeValueAsString(response))
assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00", node.get("endTime").asText())
assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z")
assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText())
assertFalse(node.get("startTime").asText().contains("["), "не ожидался zoned date-time с ID зоны")
}
}
@@ -19,9 +19,8 @@ class VisibilityPayloadParserTest {
}
@Test
fun `projects zoned instant into JVM default zone as naive local LocalDateTime`() {
// naive-время в системе — настенные часы локальной зоны JVM (без UTC-семантики). Парсер проецирует
// зонированный инстант через ZoneId.systemDefault(), поэтому на МСК (+3) 14:14Z даёт локальные 17:14.
fun `normalizes zoned instant to UTC naive LocalDateTime`() {
// Зонированный инстант должен нормализоваться в UTC-naive время независимо от зоны JVM.
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
val payload = """
[{
@@ -35,9 +34,8 @@ class VisibilityPayloadParserTest {
val window = parser.parse("20", payload).points.single().observationWindows.single()
// Инстант 14:14Z спроецирован в локальную зону JVM (МСК, +3) — настенные 17:14.
assertEquals(LocalDateTime.parse("2026-06-02T17:14:08"), window.startTime)
assertEquals(LocalDateTime.parse("2026-06-02T17:20:00"), window.endTime)
assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), window.startTime)
assertEquals(LocalDateTime.parse("2026-06-02T14:20:00"), window.endTime)
}
@Test