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:
+2
-1
@@ -15,6 +15,7 @@ import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
@@ -59,7 +60,7 @@ class BookedSlotsEventPublisher(
|
||||
val event = BookedSlotsStatusChangedEvent(
|
||||
bookedSlotIds = normalizedIds,
|
||||
status = status,
|
||||
occurredAt = LocalDateTime.now(),
|
||||
occurredAt = LocalDateTime.now(ZoneOffset.UTC),
|
||||
sourceService = applicationName,
|
||||
missionId = missionId,
|
||||
modeId = modeId,
|
||||
|
||||
+17
-7
@@ -57,6 +57,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import kotlin.math.PI
|
||||
@@ -189,14 +190,23 @@ class MissionPlaningService(
|
||||
.mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует `OffsetDateTime` к UTC и отбрасывает зону. Все naive `LocalDateTime` в сервисе — UTC
|
||||
* (см. docs/standards/DATETIME_UTC.md), поэтому сравнивать их можно только с UTC-стенкой времени мода,
|
||||
* а не с локальной стенкой произвольного офсета. Голый `toLocalDateTime()` был корректен лишь пока
|
||||
* источник слал офсет 0 (`Z`).
|
||||
*/
|
||||
private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime =
|
||||
withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
||||
|
||||
private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean {
|
||||
val modeStart = timeStart?.toLocalDateTime() ?: return false
|
||||
val modeStart = timeStart?.toUtcLocalDateTime() ?: return false
|
||||
val modeStop = modeStop() ?: return false
|
||||
return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop)
|
||||
}
|
||||
|
||||
private fun ModeEntity.modeStop(): LocalDateTime? {
|
||||
val modeStart = timeStart?.toLocalDateTime() ?: return null
|
||||
val modeStart = timeStart?.toUtcLocalDateTime() ?: return null
|
||||
val durationSeconds = when (this) {
|
||||
is SurveyModeEntity -> duration
|
||||
is DropModeEntity -> duration
|
||||
@@ -273,7 +283,7 @@ class MissionPlaningService(
|
||||
overrideIntervalEnd: LocalDateTime? = null,
|
||||
comPlanSnapshotId: Long? = null
|
||||
): MissionSurveyCalculationResponseDTO {
|
||||
val startedAt = LocalDateTime.now()
|
||||
val startedAt = LocalDateTime.now(ZoneOffset.UTC)
|
||||
logger.info(
|
||||
"Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}",
|
||||
missionId,
|
||||
@@ -361,7 +371,7 @@ class MissionPlaningService(
|
||||
)
|
||||
|
||||
if (survs.isEmpty()) {
|
||||
val finishedAt = LocalDateTime.now()
|
||||
val finishedAt = LocalDateTime.now(ZoneOffset.UTC)
|
||||
return MissionSurveyCalculationResponseDTO(
|
||||
missionId = missionId,
|
||||
completed = true,
|
||||
@@ -406,7 +416,7 @@ class MissionPlaningService(
|
||||
modeBookings.size
|
||||
)
|
||||
|
||||
val finishedAt = LocalDateTime.now()
|
||||
val finishedAt = LocalDateTime.now(ZoneOffset.UTC)
|
||||
return MissionSurveyCalculationResponseDTO(
|
||||
missionId = missionId,
|
||||
completed = true,
|
||||
@@ -677,7 +687,7 @@ class MissionPlaningService(
|
||||
}
|
||||
val start = survey.timeStart
|
||||
?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала")
|
||||
val surveyEnd = start.toLocalDateTime()
|
||||
val surveyEnd = start.toUtcLocalDateTime()
|
||||
.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
|
||||
if (!surveyEnd.isBefore(req.zoneStart)) {
|
||||
throw CustomErrorException(
|
||||
@@ -930,7 +940,7 @@ class MissionPlaningService(
|
||||
iterator.remove()
|
||||
continue
|
||||
}
|
||||
val surveyStart = surveyStartOffset.toLocalDateTime()
|
||||
val surveyStart = surveyStartOffset.toUtcLocalDateTime()
|
||||
|
||||
val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
|
||||
if (!surveyEnd.isBefore(zoneStart)) {
|
||||
|
||||
+35
@@ -153,6 +153,41 @@ class MissionPlaningServiceTest {
|
||||
assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modesBySatelliteAndInterval normalizes non-UTC mode offsets to UTC before matching`() {
|
||||
val mission = MissionEntity(missionId = UUID.randomUUID(), satelliteId = 77)
|
||||
val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1)
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 16, 10, 0, 0)
|
||||
val intervalStop = LocalDateTime.of(2026, 3, 16, 10, 30, 0)
|
||||
|
||||
// То же мгновение, что 10:10 UTC (внутри окна), но выражено со смещением +03:00 → 13:10.
|
||||
// Голый toLocalDateTime() дал бы 13:10 и выкинул мод из окна; нормализация к UTC оставляет его.
|
||||
val offsetSurvey = SurveyModeEntity(
|
||||
id = 1L,
|
||||
plan = plan,
|
||||
timeStart = OffsetDateTime.of(2026, 3, 16, 13, 10, 0, 0, ZoneOffset.ofHours(3)),
|
||||
revolution = 11,
|
||||
lat = 56.0,
|
||||
long = 38.0,
|
||||
duration = 300.0,
|
||||
contourWkt = "POLYGON((0 0,1 0,1 1,0 0))",
|
||||
roll = 1.0,
|
||||
source = SurveyModeSource.SLOT
|
||||
)
|
||||
|
||||
`when`(
|
||||
modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
|
||||
77,
|
||||
intervalStop.atOffset(ZoneOffset.UTC)
|
||||
)
|
||||
).thenReturn(listOf(offsetSurvey))
|
||||
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L))).thenReturn(emptyList())
|
||||
|
||||
val result = service.modesBySatelliteAndInterval(77, intervalStart, intervalStop)
|
||||
|
||||
assertEquals(listOf(1L), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modesBySatelliteAndInterval rejects inverted interval`() {
|
||||
val exception = assertThrows(ResponseStatusException::class.java) {
|
||||
|
||||
Reference in New Issue
Block a user