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
@@ -28,6 +28,7 @@ import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SatellitePdcmService
import space.nstart.pcp.slots_service.service.SlotService
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
@@ -103,7 +104,7 @@ class CatalogRestController(
private fun parseSendInitialConditionsTime(time: String?): LocalDateTime {
val normalizedTime = time?.trim()?.takeIf { it.isNotEmpty() }
?: return LocalDateTime.now()
?: return LocalDateTime.now(ZoneOffset.UTC)
return try {
LocalDateTime.parse(normalizedTime, sendInitialConditionsTimeFormatter)
@@ -6,6 +6,8 @@ import java.time.LocalDateTime
/**
* UI DTO for the bookings page. It intentionally lives in ui-service because the page combines
* data from slots-service, request-service and satellite-catalog-service without changing their APIs.
*
* Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md).
*/
data class BookedSlotViewDTO(
val bookedSlotId: Long,
@@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.groupstate
import java.time.LocalDateTime
/*
* Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md).
*/
data class GroupStateAvailabilityDTO(
val timeStart: LocalDateTime,
val timeStop: LocalDateTime
@@ -3,6 +3,10 @@ package space.nstart.pcp.slots_service.dto.pdcm
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import java.time.LocalDateTime
/*
* Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md).
*/
data class SatellitePdcmAvailabilityDTO(
val timeStart: LocalDateTime,
val timeStop: LocalDateTime
@@ -4,6 +4,11 @@ import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
/*
* Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md): `LocalDateTime` сериализуется как
* naive ISO без зоны (= UTC), `Instant` — с `Z`. Потребители (фронт через utils/utcTime) парсят как UTC.
*/
data class TguPlatformResponse(
val id: String?,
val businessKey: String?,
@@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tleanalysis
import java.time.LocalDateTime
/*
* Времена ([LocalDateTime], в т.ч. epoch) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md).
*/
data class TleAnalysisSatelliteDTO(
val id: Long,
val catalogId: Long? = null,
@@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tlecomparison
import java.time.LocalDateTime
/*
* Времена ([LocalDateTime], epoch/time) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md).
*/
class TleTextRequestDTO(
var tle: String = ""
)
@@ -426,7 +426,7 @@ private val docReqCovName ="req_cov_"
timeStop: LocalDateTime? = null
): Iterable<CZMLEntity> {
val tstart = timeStart ?: LocalDateTime.now()
val tstart = timeStart ?: LocalDateTime.now(ZoneOffset.UTC)
val tstop = timeStop ?: tstart.plusDays(1)
var vuz = ballisticsService.ascNodes(id, tstart, tstop).collectList().block()
vuz = vuz?.filter { it.time >= tstart && it.time <= tstop }?.sortedBy { it.time }
@@ -20,6 +20,7 @@ import space.nstart.pcp.slots_service.czml.CZMLPolygon
import space.nstart.pcp.slots_service.czml.CZMLPosition
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
class CoverageSchemeService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
@@ -185,8 +186,8 @@ class CoverageSchemeModeRowDTO(
val entityId: String = "",
val sequence: Int = 0,
val satelliteId: Long = 0,
val tn: LocalDateTime = LocalDateTime.now(),
val tk: LocalDateTime = LocalDateTime.now(),
val tn: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
val tk: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
val roll: Double? = null,
val revolutionSign: RevolutionSign? = null,
val contour: String = "",
@@ -7,6 +7,7 @@ import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
class SatellitePdcmService(
@@ -14,7 +15,7 @@ class SatellitePdcmService(
private val ballisticsService: BallisticsService
) {
fun satelliteSummaries(): List<SatellitePdcmSummaryDTO> = satelliteSummaries(LocalDateTime.now())
fun satelliteSummaries(): List<SatellitePdcmSummaryDTO> = satelliteSummaries(LocalDateTime.now(ZoneOffset.UTC))
fun satelliteSummaries(referenceTime: LocalDateTime): List<SatellitePdcmSummaryDTO> {
val satellites = satelliteCatalogService.allSatellites()
@@ -39,7 +40,7 @@ class SatellitePdcmService(
}
fun satelliteDetails(satelliteId: Long): SatellitePdcmDetailsDTO =
satelliteDetails(satelliteId, LocalDateTime.now())
satelliteDetails(satelliteId, LocalDateTime.now(ZoneOffset.UTC))
fun satelliteDetails(satelliteId: Long, referenceTime: LocalDateTime): SatellitePdcmDetailsDTO {
val satellites = satelliteCatalogService.allSatellites()
@@ -0,0 +1,62 @@
package space.nstart.pcp.slots_service.dto.tgu
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 java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
/**
* Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service:
* naive `LocalDateTime` ISO без зоны (= UTC), `Instant` ISO с `Z`
* (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта.
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
*/
class TguPlanningDtoContractTest {
private val mapper = jacksonObjectMapper().findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes plan times as naive ISO without zone`() {
val plan = TguPlanResponse(
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 = "PLANNED"
)
val node = mapper.readTree(mapper.writeValueAsString(plan))
assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
assertTrue(
node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")),
"ожидался naive ISO без зоны"
)
}
@Test
fun `serializes platform validity instants with explicit Z zone`() {
val platform = TguPlatformResponse(
id = "25544",
businessKey = "bk-1",
name = "SAT",
status = "OPERATIONAL",
noradId = 25544L,
mission = "obs",
validFrom = Instant.parse("2026-05-29T01:00:00Z"),
validTo = Instant.parse("2026-12-31T23:00:00Z")
)
val node = mapper.readTree(mapper.writeValueAsString(platform))
assertEquals("2026-05-29T01:00:00Z", node.get("validFrom").asText())
assertTrue(node.get("validTo").asText().endsWith("Z"), "ожидалась явная зона Z")
}
}