diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index c8f6c15..0e18509 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -43,8 +43,8 @@ | Сервис | Преобладает на границе API | Naive? | Вердикт | |---|---|---|---| | pcp-request-service | `OffsetDateTime` (`Z`) | нет | **эталон**: явная зона на проводе | -| pcp-tgu-service | `LocalDateTime` (planId/plan times) | да | задокументирован «=UTC» (`PlanResponse`); баги конвертаций исправлены | -| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries) | да | задокументировать «=UTC»; кандидат на `Z` | +| pcp-tgu-service | REST `PlanResponse` — `OffsetDateTime` (`Z`); Kafka/прочие DTO — `LocalDateTime` | частично | `PlanResponse` **мигрирован на `Z`**; баги конвертаций исправлены; Kafka-DTO остаются naive (=UTC) | +| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries); план ТГУ — `OffsetDateTime` (`Z`) | частично | `TguPlanResponse` **мигрирован на `Z`** вслед за tgu; прочие naive DTO — задокументировать | | pcp-complex-mission-service | `LocalDateTime` (+ часть `Instant`) | смешано | проверить смешение; задокументировать | | pcp-dynamic-plan-service | `LocalDateTime` (+ offset на входе клиента) | да | задокументировать «=UTC» | | pcp-ballistics-service | `LocalDateTime` | да | задокументировать «=UTC» | @@ -77,13 +77,24 @@ Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot — JavaTimeModule + `disable(WRITE_DATES_AS_TIMESTAMPS)`): -- `PlanResponseContractTest` (pcp-tgu-service) — naive `LocalDateTime` без зоны. +- `PlanResponseContractTest` (pcp-tgu-service) — `OffsetDateTime` с `Z` (после миграции, см. ниже). - `RequestResponseDtoContractTest` (pcp-request-service) — `OffsetDateTime` с `Z` (эталон). -- `TguPlanningDtoContractTest` (pcp-ui-service) — оба формата: naive `LocalDateTime` + `Instant` с `Z`. +- `TguPlanningDtoContractTest` (pcp-ui-service) — план ТГУ `OffsetDateTime` с `Z` + `Instant` с `Z`. Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme, mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании -пер-сервисный страж копируется по образцу `PlanResponseContractTest`. +пер-сервисный страж копируется по образцу `RequestResponseDtoContractTest`. + +### Миграция к `Z` — выполнено + +- **План ТГУ (tgu → ui-service → фронт).** `PlanResponse` (pcp-tgu-service) и зависящий от него + `TguPlanResponse` (pcp-ui-service) переведены с naive `LocalDateTime` на `OffsetDateTime` в UTC → + на проводе `Z`. Домен/БД (`PlannedPlanEntity`/`CalculatedPlan`) остались naive `LocalDateTime` (=UTC); + конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён + синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. + +Следующие кандидаты на миграцию (по согласованию, по сервису): остальные naive-DTO ui-service, +ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. ## 5. Правило для нового кода diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index 63740c0..6d746f0 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -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 ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt index 1a336fc..cf2a44b 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt @@ -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") } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index e97a4f5..355b86b 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -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) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index b426c55..a0be157 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -2,11 +2,13 @@ package space.nstart.pcp.slots_service.dto.tgu import java.time.Instant import java.time.LocalDateTime +import java.time.OffsetDateTime import java.util.UUID /* - * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md): `LocalDateTime` сериализуется как - * naive ISO без зоны (= UTC), `Instant` — с `Z`. Потребители (фронт через utils/utcTime) парсят как UTC. + * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md). Времена плана ([TguPlanResponse]) — + * `OffsetDateTime` с явной зоной `Z` (как отдаёт pcp-tgu-service); `Instant` — тоже с `Z`. `LocalDateTime` + * (напр. [TguPlanDecisionMessage.decisionTime]) — naive ISO без зоны (= UTC). Фронт парсит через utils/utcTime. */ data class TguPlatformResponse( @@ -23,8 +25,8 @@ data class TguPlatformResponse( data class TguPlanResponse( val planId: UUID, val spacecraftId: String, - val startTime: LocalDateTime, - val endTime: LocalDateTime, + val startTime: OffsetDateTime, + val endTime: OffsetDateTime, val kppId: String, val status: String ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index ac6e271..13d99ca 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -45,6 +45,8 @@ import space.nstart.pcp.slots_service.service.SlotService import space.nstart.pcp.slots_service.service.StationService import space.nstart.pcp.slots_service.service.TguPlanningService import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -210,8 +212,8 @@ class CatalogControllerTest { TguPlanResponse( planId = planId, spacecraftId = "56756", - startTime = LocalDateTime.of(2026, 5, 29, 10, 0), - endTime = LocalDateTime.of(2026, 5, 29, 10, 15), + startTime = OffsetDateTime.of(2026, 5, 29, 10, 0, 0, 0, ZoneOffset.UTC), + endTime = OffsetDateTime.of(2026, 5, 29, 10, 15, 0, 0, ZoneOffset.UTC), kppId = "KPP-1", status = "WAITING_DECISION" ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt index 8e0a67d..63225ec 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt @@ -6,13 +6,15 @@ 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.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID /** * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: - * naive `LocalDateTime` → ISO без зоны (= UTC), `Instant` → ISO с `Z` - * (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта. + * времена плана ([TguPlanResponse]) — `OffsetDateTime` с `Z` (как отдаёт pcp-tgu-service), + * `Instant` ([TguPlatformResponse.validFrom]) — тоже с `Z` (см. docs/standards/DATETIME_UTC.md). + * Тест ловит молчаливый дрейф контракта. * * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). */ @@ -22,23 +24,21 @@ class TguPlanningDtoContractTest { .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 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), + 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 = "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 без зоны" - ) + 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") } @Test diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt index 9cc4328..0fc5d44 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt @@ -116,7 +116,7 @@ class TguPlanningServiceTest { exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond( exchange, 200, - """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00","endTime":"2026-05-29T10:15:00","kppId":"KPP-1","status":"WAITING_DECISION"}]""" + """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00Z","endTime":"2026-05-29T10:15:00Z","kppId":"KPP-1","status":"WAITING_DECISION"}]""" ) exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond( exchange,