16 KiB
DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов pcp
Назначение
Этот документ фиксирует соглашение по работе со временем во всей группе сервисов pcp
и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде.
Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе,
которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч).
1. Соглашение (canonical)
- Всё время — UTC. На любом API времена представляют момент в UTC. В БД — тоже UTC.
- Формат на проводе — ISO-8601 с явной зоной
Z(предпочтительно). Где сервис исторически отдаёт naiveLocalDateTime(ISO без зоны, напр."2026-05-29T01:00:00"), это допустимо только при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию кZ. - Backend:
- «сейчас» — только UTC:
Instant.now()илиLocalDateTime.now(ZoneOffset.UTC); неLocalDateTime.now(); - конвертация
Instant/OffsetDateTime→LocalDateTimeдля naive-полей — только через UTC (LocalDateTime.ofInstant(instant, ZoneOffset.UTC),withOffsetSameInstant(ZoneOffset.UTC)), никогда черезZoneId.systemDefault()/atZone(systemDefault()); - Jackson во всех сервисах настроен с
JavaTimeModule+disable(WRITE_DATES_AS_TIMESTAMPS), поэтомуLocalDateTimeуходит как ISO без зоны, аOffsetDateTime/Instant— сZ/офсетом.
- «сейчас» — только UTC:
- Frontend (
pcp-tgu-ops-ui):- парсинг строк бэка — только через
src/utils/utcTime.ts(parseUtc); никаких «голых»new Date(string)/Date.parse(string); - отображение — только UTC (
formatUtcDateTime/Date/TimeлибоtoLocale*сtimeZone: "UTC"); <input type="datetime-local">— значение черезtoUtcInputValue, чтение черезparseUtc, отправка на offset-бэк — черезtoUtcIso.- Инвариант закреплён тестом-стражем
src/utils/utcTime.guard.test.ts(падает на запрещённых паттернах).
- парсинг строк бэка — только через
- Никакой неявной локализации под пояс машины ни на фронте, ни в логах/ответах бэка.
2. Эталоны переиспользования
- Frontend:
services/pcp-tgu-ops-ui/src/utils/utcTime.ts— единая точка парсинга/форматирования. - Backend (чтение):
libs/pcp-types-libutils/json/PcpTimeModule— слой толерантности (бэковый аналогparseUtc): любой потребитель читает и naive(=UTC), иZ/офсет (см. раздел ниже). - Backend (запись):
pcp-request-serviceотдаётOffsetDateTime(строки сZ) и конвертирует вход черезOffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()— корректный образец.
2bis. Слой толерантности времени (включатель безопасного флипа)
PcpTimeModule (libs/pcp-types-lib/.../utils/json/PcpTimeModule.kt) — Jackson-модуль с
толерантными десериализаторами для LocalDateTime, OffsetDateTime, Instant. Любой
потребитель с ним читает время в обоих форматах на проводе и нормализует к UTC:
| Тип поля | naive …T01:00:00 |
с зоной …T01:00:00Z / +03:00 |
|---|---|---|
LocalDateTime |
как есть (= UTC) | приводится к UTC, зона отбрасывается |
OffsetDateTime |
atOffset(UTC) |
withOffsetSameInstant(UTC) |
Instant |
трактуется как момент UTC | штатно |
Модуль трогает только чтение — формат записи определяется типом поля и не меняется, поэтому подключение слоя само по себе не сдвигает ни один контракт.
Зачем. Микросервисы деплоятся независимо. Раньше перевод продюсера LocalDateTime → OffsetDateTime
(на проводе появляется Z) ломал ещё не обновлённого потребителя — штатный LocalDateTimeDeserializer
кидает исключение на офсете. С толерантностью во всём флоте продюсера можно флипать в любом порядке,
без синхронного деплоя. Это снимает требование «продюсер + все потребители одним коммитом».
Подключение (три механизма, чтобы покрыть все мапперы):
- Spring Boot auto-configuration
PcpTimeJacksonAutoConfiguration(@Bean Module) +META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports— Boot-managedObjectMapperкаждого из 12 сервисов, зависящих от pcp-types-lib (правок в сервисах не требуется). - Jackson SPI
META-INF/services/com.fasterxml.jackson.databind.Module— ручные мапперы сfindAndRegisterModules()(напр.pcp-request-service). - Точечная регистрация в мапперах, что обходят оба:
pcp-dynamic-plan-service/JacksonConfig.
Особый случай — pcp-tgu-service. Он не зависит от pcp-types-lib и работает на WebFlux
(подключение либы притянуло бы spring-boot-starter-web/MVC и переключило тип приложения на
сервлетный). Поэтому в tgu лежат два локальных зеркала (поведение обязано совпадать с
библиотечным, правки синхронизировать), покрывающих обе Jackson-среды сервиса:
config/PcpTimeModule.kt(Jackson 2) — зарегистрирован вCamundaJacksonConfig, покрывает Camunda/внутренний парсинг;config/PcpTimeModuleV3.kt(Jackson 3,tools.jackson.*) — REST-кодеки WebFlux, которые Boot 4 авто-конфигурирует на Jackson 3JsonMapper. Регистрируется биномtools.jackson.databind.JacksonModule(WebFluxJacksonConfig);JacksonAutoConfigurationсобирает все такие бины в общийJsonMapper(builder.addModules(...)). Тем самым входящий REST-JSON tgu тоже читается толерантно к обоим форматам.
Контракт слоя закреплён PcpTimeModuleTest (pcp-types-lib): оба формата → один и тот же UTC-момент,
плюс проверка авто-подхвата через SPI.
3. Состояние сервисов (срез аудита)
Формат на проводе определяется Jackson-сериализацией: LocalDateTime → ISO без зоны (naive, = UTC),
OffsetDateTime/Instant → ISO с Z. «Потребители» — кто читает время этого сервиса.
| Сервис | Преобладает на границе API | Naive? | Вердикт |
|---|---|---|---|
| pcp-request-service | OffsetDateTime (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» |
| pcp-mission-planing-service | LocalDateTime (вход — OffsetDateTime) |
да | задокументировать «=UTC» |
| slots-service | LocalDateTime |
да | задокументировать «=UTC»; проверить rs.getTimestamp().toLocalDateTime() |
| pcp-coverage-scheme-service | LocalDateTime |
да | задокументировать «=UTC» |
| pcp-route-processing-service | нет времени на границе API | — | — |
| pcp-satellite-catalog-service | нет времени на границе API | — | — |
| pcp-stations-service | нет времени на границе API | — | — |
| tle-monitoring-service | нет времени на границе API | — | — |
4. Известные расхождения и под-задачи (миграция к Z — не «большим взрывом»)
Систематический класс: LocalDateTime.now() без UTC-зоны (~80 мест: entity-дефолты createdAt/updatedAt,
воркеры, use case'ы). На не-UTC JVM пишет локальное «сейчас» в UTC-поля. Низкий риск для audit-колонок,
но формально неверно. Рекомендованные под-задачи по сервисам:
- pcp-tgu-service — заменить оставшиеся
LocalDateTime.now()(воркеры выдачи/решений,PlanQueryServicecutoff, entity-дефолты) наLocalDateTime.now(ZoneOffset.UTC); тест на cutoff истории. - slots-service — проверить
SlotRepositoryImpl.rs.getTimestamp("tn").toLocalDateTime()(зависит от зоны драйвера); зафиксировать чтение в UTC. - pcp-mission-planing-service / pcp-dynamic-plan-service —
OffsetDateTime.toLocalDateTime()теряет офсет; убедиться, что вход всегда UTC, иначе конвертировать черезwithOffsetSameInstant(UTC). - Все сервисы с naive
LocalDateTimeна границе — добавить сериализационный тест, фиксирующий формат на проводе (естьZ/ нет зоны), затем по согласованию мигрировать DTO кOffsetDateTime/Instant(UTC). ФронтовыйparseUtcуже совместим с обоими форматами, поэтому миграция безопасна при пер-сервисном раскате.
Контрактные тесты формата (зафиксировано)
Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot —
JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS)):
PlanResponseContractTest(pcp-tgu-service) —OffsetDateTimeсZ(после миграции, см. ниже).RequestResponseDtoContractTest(pcp-request-service) —OffsetDateTimeсZ(эталон).TguPlanningDtoContractTest(pcp-ui-service) — план ТГУOffsetDateTimeсZ+InstantсZ.
Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme,
mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании
пер-сервисный страж копируется по образцу RequestResponseDtoContractTest.
Миграция к Z — выполнено
-
План ТГУ (tgu → ui-service → фронт).
PlanResponse(pcp-tgu-service) и зависящий от негоTguPlanResponse(pcp-ui-service) переведены с naiveLocalDateTimeнаOffsetDateTimeв UTC → на проводеZ. Домен/БД (PlannedPlanEntity/CalculatedPlan) остались naiveLocalDateTime(=UTC); конвертация только на границе (atOffset(ZoneOffset.UTC)). Потребитель один (ui-service), обновлён синхронно; фронтparseUtcсовместим. Контракт закреплён тестами выше. -
Слой толерантности (
PcpTimeModule) — раскатан по флоту. 12 сервисов на pcp-types-lib получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — двумя локальными зеркалами (Jackson-2 для Camunda и Jackson-3 для REST-кодеков WebFlux). Это включатель: дальнейший флип продюсеров наZбольше не требует синхронного деплоя.
После толерантности продюсеров можно флипать в любом порядке. Кандидаты: остальные naive-DTO ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme.
Исправленные баги формата
KafkaMessage.time(общий Kafka-конверт, pcp-types-lib) — исправлено. Былоtime = LocalDateTime.now()(локальное!) + сериализацияLocalDateTimeTimeZoneSerializerчерезZoneId.systemDefault(): конверт нёс МСК-стенные часы с+03:00, а потребитель (формат…SSS[xxx]вLocalDateTime) отбрасывал зону → сдвиг на пояс. Сталоnow(ZoneOffset.UTC)+ сериализацияatOffset(ZoneOffset.UTC)→ на проводе…+00:00. Инстант не изменился (тот же момент), но стенные часы теперь совпадают с UTC, поэтому потребитель, дропающий зону, получает корректное время. ЗакрепленоLocalDateTimeTimeZoneSerializerTest.
Исправленные баги формата
- pcp-tgu-service REST-codec на Jackson 3 — теперь толерантен (см. 2bis). Добавлен Jackson-3
вариант модуля
config/PcpTimeModuleV3.ktи его регистрация биномJacksonModule(config/WebFluxJacksonConfig.kt); входящий REST-JSON WebFlux читается в обоих форматах (naive=UTC иZ). ЗакрепленоPcpTimeModuleV3Test.
Остаток (под-задачи)
- Обе Jackson-среды tgu теперь толерантны; отдельных остатков по чтению времени нет. При миграции
оставшихся продьюсеров на
Z(раздел 4) добавлять сериализационные тесты контракта.
5. Правило для нового кода
- Frontend: только
utils/utcTime(стражutcTime.guard.test.tsне даст просочиться нарушителю). - Backend: UTC-
now; на новых API предпочтителен явныйZ(OffsetDateTime/Instant). - Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).