pcp: миграция времени плана ТГУ на явный Z (OffsetDateTime)
PlanResponse (pcp-tgu-service) и зависящий от него TguPlanResponse (pcp-ui-service) переведены с naive LocalDateTime на OffsetDateTime в UTC → на проводе явная зона Z. Домен/БД (PlannedPlanEntity/CalculatedPlan) остаются naive LocalDateTime (= UTC); конвертация только на границе API (atOffset(ZoneOffset.UTC)). Потребитель один (ui-service.TguPlanningService), обновлён синхронно; фронт parseUtc совместим с Z. - контрактные тесты обновлены на Z (PlanResponseContractTest, TguPlanningDtoContractTest); - тестовые фикстуры/ассерты приведены к OffsetDateTime/Z (PlanQueryServiceTest, CatalogControllerTest, TguPlanningServiceTest); - docs/standards/DATETIME_UTC.md: отражена миграция и обновлена таблица сервисов.
This commit is contained in:
+16
-13
@@ -4,16 +4,17 @@ import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
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.
|
||||
* Времена ([startTime]/[endTime]) — `OffsetDateTime` в UTC и сериализуются как ISO-строка с явной
|
||||
* зоной `Z` (напр. `"2026-05-29T01:00:00Z"`), см. `docs/standards/DATETIME_UTC.md`. Внутреннее
|
||||
* хранение и домен (`PlannedPlanEntity`/`CalculatedPlan`) остаются naive `LocalDateTime` (= UTC);
|
||||
* конвертация в `Z` происходит только на границе API.
|
||||
*/
|
||||
data class PlanResponse(
|
||||
|
||||
@@ -23,11 +24,11 @@ data class PlanResponse(
|
||||
/** Идентификатор космического аппарата */
|
||||
val spacecraftId: String,
|
||||
|
||||
/** Время начала плана (UTC, naive ISO без зоны). */
|
||||
val startTime: LocalDateTime,
|
||||
/** Время начала плана (UTC, ISO с `Z`). */
|
||||
val startTime: OffsetDateTime,
|
||||
|
||||
/** Время окончания плана (UTC, naive ISO без зоны). */
|
||||
val endTime: LocalDateTime,
|
||||
/** Время окончания плана (UTC, ISO с `Z`). */
|
||||
val endTime: OffsetDateTime,
|
||||
|
||||
/** Идентификатор пункта закладки */
|
||||
val kppId: String,
|
||||
@@ -45,8 +46,9 @@ data class PlanResponse(
|
||||
PlanResponse(
|
||||
planId = plan.planId,
|
||||
spacecraftId = plan.spacecraftId,
|
||||
startTime = plan.startTime,
|
||||
endTime = plan.endTime,
|
||||
// naive LocalDateTime в БД = UTC → отдаём с явной зоной Z.
|
||||
startTime = plan.startTime.atOffset(ZoneOffset.UTC),
|
||||
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
|
||||
kppId = plan.kppId,
|
||||
status = plan.status
|
||||
)
|
||||
@@ -61,8 +63,9 @@ data class PlanResponse(
|
||||
PlanResponse(
|
||||
planId = deterministicPlanId(plan),
|
||||
spacecraftId = plan.spacecraftId,
|
||||
startTime = plan.startTime,
|
||||
endTime = plan.endTime,
|
||||
// naive LocalDateTime расчёта = UTC → отдаём с явной зоной Z.
|
||||
startTime = plan.startTime.atOffset(ZoneOffset.UTC),
|
||||
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
|
||||
kppId = plan.kppId,
|
||||
status = PlannedPlanStatus.PLANNED
|
||||
)
|
||||
|
||||
+11
-14
@@ -6,13 +6,14 @@ 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.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Контракт формата времени на проводе для [PlanResponse]: naive ISO без зоны (= UTC,
|
||||
* см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля
|
||||
* (на `OffsetDateTime`/`Instant`) или Jackson-конфига, которые поломали бы потребителей.
|
||||
* Контракт формата времени на проводе для [PlanResponse]: `OffsetDateTime` в UTC → ISO с явной зоной `Z`
|
||||
* (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля
|
||||
* (назад на naive `LocalDateTime`) или Jackson-конфига, которые поломали бы потребителей.
|
||||
*
|
||||
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
|
||||
*/
|
||||
@@ -22,24 +23,20 @@ class PlanResponseContractTest {
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
|
||||
@Test
|
||||
fun `serializes plan times as naive ISO without zone`() {
|
||||
fun `serializes plan times as ISO with explicit Z 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),
|
||||
startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC),
|
||||
endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC),
|
||||
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 без зоны"
|
||||
)
|
||||
assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText())
|
||||
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText())
|
||||
assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z")
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -39,9 +39,10 @@ class PlanQueryServiceTest {
|
||||
val result = service.getAllPlans(historyDays = 7)
|
||||
|
||||
assertEquals(2, result.size)
|
||||
val persistedResponse = result.first { it.startTime == persisted.startTime }
|
||||
// PlanResponse.startTime — OffsetDateTime(UTC); доменные времена naive LocalDateTime (= UTC).
|
||||
val persistedResponse = result.first { it.startTime == persisted.startTime.atOffset(ZoneOffset.UTC) }
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status)
|
||||
val projectedResponse = result.first { it.startTime == freshProjected.startTime }
|
||||
val projectedResponse = result.first { it.startTime == freshProjected.startTime.atOffset(ZoneOffset.UTC) }
|
||||
assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status)
|
||||
// Отсортировано по startTime.
|
||||
assertTrue(result[0].startTime <= result[1].startTime)
|
||||
|
||||
Reference in New Issue
Block a user