pcp: единообразие времени (UTC) по группе сервисов

Фронт (pcp-tgu-ops-ui):
- тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) /
  toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc.

Бэкенд:
- UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам
  (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах.
- Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе):
  - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime);
  - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime);
  - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC);
  - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()).
- Контрактные сериализационные тесты формата на проводе (naive без зоны / Z):
  PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui).
- KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ).

Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z),
docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
Дмитрий Соловьев
2026-06-02 21:51:38 +03:00
parent ac2220d063
commit 260583b134
84 changed files with 760 additions and 166 deletions
@@ -9,6 +9,11 @@ import java.util.UUID
/**
* DTO ответа REST API для плана.
*
* Времена ([startTime]/[endTime]) — `LocalDateTime` и сериализуются как ISO-строка без зоны
* (напр. `"2026-05-29T01:00:00"`). По соглашению системы это **UTC** (см. `docs/standards/DATETIME_UTC.md`):
* потребители обязаны парсить эти строки как UTC (фронт — через `utils/utcTime.parseUtc`), а не как
* локальное время. Кандидат на миграцию к явному `Z` (`OffsetDateTime`/`Instant`) на границе API.
*/
data class PlanResponse(
@@ -18,10 +23,10 @@ data class PlanResponse(
/** Идентификатор космического аппарата */
val spacecraftId: String,
/** Время начала плана */
/** Время начала плана (UTC, naive ISO без зоны). */
val startTime: LocalDateTime,
/** Время окончания плана */
/** Время окончания плана (UTC, naive ISO без зоны). */
val endTime: LocalDateTime,
/** Идентификатор пункта закладки */
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -39,10 +40,10 @@ class AcceptedPlanSlotsUpdateJobEntity(
var lastError: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(planId = UUID.randomUUID())
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -45,10 +46,10 @@ class PlanIssueAttemptEntity(
var finishedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0)
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -48,10 +49,10 @@ class PlannedPlanEntity(
var acceptedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -18,7 +19,7 @@ class ProcessedPlanDecisionEventEntity(
var eventId: UUID,
@Column(name = "received_at", nullable = false)
var receivedAt: LocalDateTime = LocalDateTime.now()
var receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(eventId = UUID.randomUUID())
@@ -6,6 +6,7 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
@Entity
@@ -35,7 +36,7 @@ class SpacecraftPlanningStateEntity(
var urgentReissueFrom: LocalDateTime? = null,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для 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 java.time.ZoneOffset
/**
* 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 = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для 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 java.time.ZoneOffset
/**
* Полный 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 = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для 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 java.time.ZoneOffset
/**
* 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 = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "")
@@ -12,6 +12,7 @@ import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientException
import org.springframework.web.reactive.function.client.WebClientResponseException
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
@@ -36,7 +37,7 @@ class ExternalPointsClient(
* которые разрешены для работы TGU.
*/
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> {
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
return platformService.loadAllowedPlatforms()
@@ -8,7 +8,7 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
@@ -19,7 +19,6 @@ import java.time.temporal.ChronoUnit
class VisibilityPayloadParser(
private val objectMapper: ObjectMapper
) {
private val zoneId: ZoneId = ZoneId.systemDefault()
/** Группирует RVA records по станциям и сортирует окна для алгоритма планирования. */
fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints {
@@ -44,7 +43,10 @@ class VisibilityPayloadParser(
}
/**
* Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client.
* Разбирает UTC/ISO строку времени баллистики в naive `LocalDateTime` (по соглашению системы — UTC).
*
* Момент из `Instant.parse` берём в UTC (а не в поясе JVM): naive-поля `planned_plan.start_time/end_time`
* по контракту хранят UTC, поэтому `atZone(systemDefault)` на не-UTC машине сдвигал бы время на офсет.
*
* Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time`
* хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется.
@@ -53,7 +55,7 @@ class VisibilityPayloadParser(
*/
private fun toLocalDateTime(value: String): LocalDateTime =
try {
Instant.parse(value).atZone(zoneId).toLocalDateTime()
LocalDateTime.ofInstant(Instant.parse(value), ZoneOffset.UTC)
} catch (_: DateTimeParseException) {
LocalDateTime.parse(value)
}.truncatedTo(ChronoUnit.MICROS)
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -16,6 +17,6 @@ class AcceptedPlanSlotsUpdateJobService(
/** Idempotent enqueue: один planId получает максимум одну job. */
@Transactional
fun enqueue(planId: UUID) {
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now())
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now(ZoneOffset.UTC))
}
}
@@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobStatus
import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
import java.net.InetAddress
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -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 = LocalDateTime.now(ZoneOffset.UTC)
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 = LocalDateTime.now(ZoneOffset.UTC)
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 = LocalDateTime.now(ZoneOffset.UTC)
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS
job.processInstanceKey = processInstanceKey ?: job.processInstanceKey
@@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
@@ -115,7 +116,7 @@ class HandlePlanDecisionUseCase(
return
}
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
plan.status = terminalStatus
plan.updatedAt = now
plannedPlanRepository.save(plan)
@@ -127,7 +128,7 @@ class HandlePlanDecisionUseCase(
message: PlanDecisionMessage,
attempt: PlanIssueAttemptEntity
) {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
attempt.status = when (message.decision) {
PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED
PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED
@@ -144,7 +145,7 @@ class HandlePlanDecisionUseCase(
plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity
) {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.AWAITING_LAYIN
@@ -170,7 +171,7 @@ class HandlePlanDecisionUseCase(
plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity
) {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.REJECTED
@@ -203,7 +204,7 @@ class HandlePlanDecisionUseCase(
}
private fun claimEvent(eventId: java.util.UUID): Boolean =
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now(ZoneOffset.UTC)) != 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 java.time.ZoneOffset
/**
* На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА.
@@ -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, LocalDateTime.now(ZoneOffset.UTC)) != 0
if (!claimed) {
return
}
@@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -56,7 +57,7 @@ class IssueDuePlanWorker(
/** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
val state = getOrCreateLockedState(spacecraftId)
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
return null
@@ -132,7 +133,7 @@ class IssueDuePlanWorker(
}
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
val state = getOrCreateLockedState(plan.spacecraftId)
if (state.activeAttemptId != null) {
@@ -207,7 +208,7 @@ class IssueDuePlanWorker(
private fun markStarted(response: ProcessStartResponse) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult
@@ -231,7 +232,7 @@ class IssueDuePlanWorker(
private fun markAmbiguous(preparedAttempt: PreparedAttempt) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult
attempt.status = PlanIssueAttemptStatus.AMBIGUOUS
@@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -62,7 +63,7 @@ class PlanQueryService(
.sortedBy { response -> response.startTime }
private fun historySince(historyDays: Long): LocalDateTime =
LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0))
LocalDateTime.now(ZoneOffset.UTC).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 java.time.ZoneOffset
/**
* Сохраняет 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 = LocalDateTime.now(ZoneOffset.UTC)
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 java.time.ZoneOffset
/**
* Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs.
@@ -26,7 +26,7 @@ class SyncSpacecraftFromNsiUseCase(
}
private fun persistAllowedSpacecraft(allowedPlatforms: List<PlatformCatalogItem>) {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() }
allowedPlatforms.forEach { platform ->
@@ -40,8 +40,10 @@ class SyncSpacecraftFromNsiUseCase(
name = platform.name,
status = platform.status,
mission = platform.mission,
validFrom = platform.validFrom?.atZone(ZoneId.systemDefault())?.toLocalDateTime(),
validTo = platform.validTo?.atZone(ZoneId.systemDefault())?.toLocalDateTime(),
// validFrom/validTo приходят как Instant; в naive-поля БД пишем UTC-стенку, а не пояс JVM
// (по соглашению системы naive LocalDateTime = UTC) — иначе на не-UTC машине время уезжало бы.
validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) },
validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) },
now = now
)
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC")
@@ -11,6 +11,7 @@ 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.time.ZoneOffset
import java.util.UUID
/**
@@ -66,7 +67,7 @@ class TestPlanDecisionService(
planId = plan.planId,
attemptId = attempt.attemptId,
decision = decision,
decisionTime = LocalDateTime.now(),
decisionTime = LocalDateTime.now(ZoneOffset.UTC),
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
)
handlePlanDecisionUseCase.handle(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 java.time.ZoneOffset
/**
* 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, LocalDateTime.now(ZoneOffset.UTC))
}
}
@@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository
import java.net.InetAddress
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
@@ -38,7 +39,7 @@ class VisibilityRefreshWorker(
private fun claimJobs(limit: Int): List<VisibilityRefreshJobEntity> =
transactionTemplate.execute {
val now = LocalDateTime.now()
val now = LocalDateTime.now(ZoneOffset.UTC)
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 = LocalDateTime.now(ZoneOffset.UTC).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, LocalDateTime.now(ZoneOffset.UTC))
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 = LocalDateTime.now(ZoneOffset.UTC)
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 = LocalDateTime.now(ZoneOffset.UTC)
visibilityRefreshJobRepository.save(job)
}
}
@@ -102,7 +103,7 @@ class VisibilityRefreshWorker(
job.reason = reason
job.lockedBy = null
job.lockedUntil = null
job.updatedAt = LocalDateTime.now()
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC)
visibilityRefreshJobRepository.save(job)
}
}
@@ -0,0 +1,45 @@
package space.nstart.pcp_tgu_service.dto
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import java.time.LocalDateTime
import java.util.UUID
/**
* Контракт формата времени на проводе для [PlanResponse]: naive ISO без зоны (= UTC,
* см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля
* (на `OffsetDateTime`/`Instant`) или Jackson-конфига, которые поломали бы потребителей.
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
*/
class PlanResponseContractTest {
private val mapper = jacksonObjectMapper().findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes plan times as naive ISO without zone`() {
val response = PlanResponse(
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
spacecraftId = "25544",
startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
kppId = "KPP-1",
status = PlannedPlanStatus.PLANNED
)
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())
// Нет суффикса зоны (Z/±офсет) — иначе потребитель, читающий naive как UTC, ошибётся.
assertTrue(
node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")),
"ожидался naive ISO без зоны"
)
}
}
@@ -1,14 +1,45 @@
package space.nstart.pcp_tgu_service.integration.api
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.util.TimeZone
class VisibilityPayloadParserTest {
private val parser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules())
private val originalTimeZone: TimeZone = TimeZone.getDefault()
@AfterEach
fun restoreTimeZone() {
TimeZone.setDefault(originalTimeZone)
}
@Test
fun `parses UTC instant as UTC LocalDateTime regardless of JVM default zone`() {
// planned_plan.start_time/end_time по контракту хранят UTC. Раньше парсер конвертировал момент
// через ZoneId.systemDefault(), и на не-UTC JVM (напр. МСК, +3) время уезжало на офсет.
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
val payload = """
[{
"noradId": 20,
"stationId": 102,
"onStart": {"time": "2026-06-02T14:14:08Z", "elevation": 5.0},
"onStop": {"time": "2026-06-02T14:20:00Z", "elevation": 5.0},
"onMaximum": {"time": "2026-06-02T14:17:00Z", "elevation": 45.0}
}]
""".trimIndent()
val window = parser.parse("20", payload).points.single().observationWindows.single()
// Значение совпадает с UTC-стенкой инстанта (14:14), а не со сдвигом на пояс JVM (17:14).
assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), window.startTime)
assertEquals(LocalDateTime.parse("2026-06-02T14:20:00"), window.endTime)
}
@Test
fun `truncates window times to microseconds to match persisted timestamp precision`() {
// Баллистика отдаёт наносекунды; БД (timestamp(6)) хранит микросекунды. Парсер обязан
@@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
class HandleStationParamsChangedUseCaseTest {
@@ -40,7 +41,7 @@ class HandleStationParamsChangedUseCaseTest {
StationParamsChangedMessage(
eventId = UUID.randomUUID(),
stationId = "ST-7",
occurredAt = LocalDateTime.now(),
occurredAt = LocalDateTime.now(ZoneOffset.UTC),
reason = "OUT_OF_SERVICE"
)
@@ -27,6 +27,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional
import java.util.UUID
@@ -258,7 +259,9 @@ class IssueDuePlanWorkerOnDemandTest {
private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")
private fun now(): LocalDateTime = LocalDateTime.now()
// Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now(ZoneOffset.UTC)),
// иначе на не-UTC JVM подготовленные относительно "сейчас" окна расходятся с часами воркера на офсет.
private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30)
@@ -31,6 +31,7 @@ import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventReposit
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional
import java.util.UUID
@@ -86,7 +87,7 @@ class PlanIssueAndDecisionFlowTest {
@Test
fun `rejected decision marks plan REJECTED and arms urgent reissue`() {
val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION)
val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION)
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
@@ -293,7 +294,7 @@ class PlanIssueAndDecisionFlowTest {
endTime = end,
kppId = "KPP-1",
status = status,
issueNotBefore = LocalDateTime.now().minusMinutes(1)
issueNotBefore = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1)
)
private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity =
@@ -316,7 +317,7 @@ class PlanIssueAndDecisionFlowTest {
planId = planId,
attemptId = attemptId,
decision = decision,
decisionTime = LocalDateTime.now(),
decisionTime = LocalDateTime.now(ZoneOffset.UTC),
reason = null
)
@@ -325,11 +326,11 @@ class PlanIssueAndDecisionFlowTest {
eventId = UUID.randomUUID(),
spacecraftId = spacecraftId,
planId = planId,
occurredAt = LocalDateTime.now(),
occurredAt = LocalDateTime.now(ZoneOffset.UTC),
reason = null
)
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30)
@@ -18,6 +18,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional
import java.util.UUID
@@ -176,13 +177,13 @@ class PlanProjectionServiceTest {
noradId = 25544L,
payloadJson = "[]",
payloadHash = "hash",
fetchedAt = LocalDateTime.now()
fetchedAt = LocalDateTime.now(ZoneOffset.UTC)
)
private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
@Suppress("UNCHECKED_CAST")
private fun <T> anyNonNull(): T {
@@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
class PlanQueryServiceTest {
@@ -74,5 +75,5 @@ class PlanQueryServiceTest {
private fun calculated(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
}
@@ -0,0 +1,90 @@
package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.transaction.TransactionStatus
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.Instant
import java.time.LocalDateTime
import java.util.TimeZone
import java.util.function.Consumer
class SyncSpacecraftFromNsiUseCaseTest {
private val originalTimeZone: TimeZone = TimeZone.getDefault()
@AfterEach
fun restoreTimeZone() {
TimeZone.setDefault(originalTimeZone)
}
@Test
fun `converts NSI validity instants to UTC LocalDateTime regardless of JVM default zone`() {
// validFrom/validTo пишутся в naive-поля БД, которые по контракту хранят UTC. Раньше конвертация
// шла через ZoneId.systemDefault(), и на не-UTC JVM (МСК, +3) значение уезжало на офсет.
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
val platformService = mock(PlatformService::class.java)
val repository = mock(SpacecraftSnapshotRepository::class.java)
val refreshJobService = mock(VisibilityRefreshJobService::class.java)
val transactionTemplate = mock(TransactionTemplate::class.java)
// executeWithoutResult просто прогоняет переданный блок без реальной транзакции.
doAnswer { invocation ->
invocation.getArgument<Consumer<TransactionStatus>>(0).accept(mock(TransactionStatus::class.java))
null
}.`when`(transactionTemplate).executeWithoutResult(anyNonNull())
// loadAllowedPlatforms имеет дефолтный аргумент Instant.now(); матчер нужен, иначе stub не совпадёт.
`when`(platformService.loadAllowedPlatforms(anyNonNull())).thenReturn(
listOf(
PlatformCatalogItem(
nsiId = "nsi-1",
businessKey = "bk-1",
name = "SAT",
status = "OPERATIONAL",
noradId = 25544L,
mission = "obs",
validFrom = Instant.parse("2026-06-02T14:14:08Z"),
validTo = Instant.parse("2026-12-31T23:00:00Z")
)
)
)
SyncSpacecraftFromNsiUseCase(platformService, repository, refreshJobService, transactionTemplate)
.syncAllowedSpacecraft()
val validFrom = ArgumentCaptor.forClass(LocalDateTime::class.java)
val validTo = ArgumentCaptor.forClass(LocalDateTime::class.java)
verify(repository).upsertFromNsi(
spacecraftId = anyNonNull(),
noradId = org.mockito.Mockito.anyLong(),
nsiId = anyNullable(),
businessKey = anyNullable(),
name = anyNullable(),
status = anyNullable(),
mission = anyNullable(),
validFrom = validFrom.capture(),
validTo = validTo.capture(),
now = anyNonNull()
)
assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), validFrom.value)
assertEquals(LocalDateTime.parse("2026-12-31T23:00:00"), validTo.value)
}
@Suppress("UNCHECKED_CAST")
private fun <T> anyNonNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
private fun <T> anyNullable(): T? = org.mockito.ArgumentMatchers.any()
}
@@ -21,6 +21,7 @@ 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.time.ZoneOffset
import java.util.Optional
import java.util.UUID
@@ -91,8 +92,8 @@ class TestPlanDecisionServiceTest {
PlannedPlanEntity(
planId = UUID.randomUUID(),
spacecraftId = "25544",
startTime = LocalDateTime.now().plusHours(1),
endTime = LocalDateTime.now().plusHours(2),
startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(1),
endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(2),
kppId = "KPP-1",
status = PlannedPlanStatus.WAITING_DECISION
)
@@ -13,9 +13,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity
import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser
import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository
import java.security.MessageDigest
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Optional
class VisibilityFlowTest {
@@ -28,8 +26,9 @@ class VisibilityFlowTest {
assertEquals("25544", points.spacecraftId)
assertEquals(listOf("7", "9"), points.points.map { point -> point.pointId })
// Парсер трактует UTC-инстант баллистики как UTC-стенку (naive LocalDateTime = UTC), без сдвига на пояс JVM.
assertEquals(
Instant.parse("2026-05-26T10:00:00Z").atZone(ZoneId.systemDefault()).toLocalDateTime(),
LocalDateTime.parse("2026-05-26T10:00:00"),
points.points[1].observationWindows.single().startTime
)
}