PcpTimeModule (libs/pcp-types-lib): толерантные Jackson-десериализаторы LocalDateTime/OffsetDateTime/Instant — любой потребитель читает оба формата на проводе и нормализует к UTC. Только чтение, формат записи не меняется. Подключение фронтом по флоту: - Spring Boot auto-configuration + AutoConfiguration.imports — Boot-маппер всех 12 сервисов на pcp-types-lib (без правок в сервисах); - Jackson SPI (META-INF/services) — ручные мапперы с findAndRegisterModules(); - точечно: dynamic-plan/JacksonConfig (ручной ObjectMapper); - tgu (WebFlux, не зависит от либы) — локальное зеркало модуля в CamundaJacksonConfig (Jackson-2 путь). Зачем: микросервисы деплоятся независимо; с толерантностью продюсера можно флипать LocalDateTime -> OffsetDateTime (Z) в любом порядке без синхронного деплоя — потребитель не падает на парсинге офсета. Тесты: PcpTimeModuleTest (оба формата -> один UTC-момент + авто-подхват SPI). Доки: DATETIME_UTC.md (раздел 2bis + найденные баги формата), CHECKLIST.md.
15 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 лежит локальное зеркало config/PcpTimeModule.kt (поведение обязано
совпадать с библиотечным), зарегистрированное в Jackson-2 бине CamundaJacksonConfig — покрывает
Camunda/внутренний парсинг. Остаётся под-задача: REST-codec WebFlux на Jackson 3
(tools.jackson.*, иной API) ещё не толерантен — нужен Jackson-3 вариант модуля + регистрация в
кодеках.
Контракт слоя закреплён 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 путь). Это включатель: дальнейший флип продюсеров на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()→ UTC и сериализатор конверта → UTC/Z. Раньше блокировалось риском поломки потребителей; со слоем толерантности — безопасно.
- сериализуется
- pcp-tgu-service REST-codec на Jackson 3 — ещё не толерантен (см. 2bis); под-задача на Jackson-3 вариант модуля.
5. Правило для нового кода
- Frontend: только
utils/utcTime(стражutcTime.guard.test.tsне даст просочиться нарушителю). - Backend: UTC-
now; на новых API предпочтителен явныйZ(OffsetDateTime/Instant). - Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).