e68c17e792
WebFlux в tgu был фиктивным: блокирующая JPA, контроллеры оборачивали
блокирующие вызовы в Mono.fromCallable{}.subscribeOn(boundedElastic()),
WebClient .block()-ался сразу. Единственный WebFlux-only сервис флота.
- build: подключён pcp-types-lib (тянет starter-web → servlet), springdoc
webflux-ui → webmvc-ui; starter-webflux оставлен только ради WebClient
- контроллеры возвращают значения напрямую (без Mono-обёрток)
- удалено локальное Jackson-2 зеркало PcpTimeModule.kt → модуль из либы
в CamundaJacksonConfig; Jackson-3 зеркало остаётся (Boot 4 держит
Jackson 3 на HTTP-границе и для MVC), WebFluxJacksonConfig → RestJacksonConfig
183 lines
17 KiB
Markdown
183 lines
17 KiB
Markdown
# DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов `pcp`
|
||
|
||
## Назначение
|
||
|
||
Этот документ фиксирует соглашение по работе со временем во всей группе сервисов `pcp`
|
||
и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде.
|
||
Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе,
|
||
которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч).
|
||
|
||
## 1. Соглашение (canonical)
|
||
|
||
1. **Всё время — UTC.** На любом API времена представляют момент в UTC. В БД — тоже UTC.
|
||
2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис исторически
|
||
отдаёт naive `LocalDateTime` (ISO без зоны, напр. `"2026-05-29T01:00:00"`), это допустимо
|
||
**только** при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию к `Z`.
|
||
3. **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`/офсетом.
|
||
4. **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` (падает на запрещённых паттернах).
|
||
5. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка.
|
||
|
||
## 2. Эталоны переиспользования
|
||
|
||
- Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования.
|
||
- Backend (чтение): `libs/pcp-types-lib` `utils/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-managed
|
||
`ObjectMapper` каждого из 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.** Сервис мигрирован с WebFlux на **servlet/MVC** (см. ниже
|
||
«Миграция tgu на MVC») и теперь зависит от pcp-types-lib, как остальной флот. Но из-за Boot 4 на
|
||
HTTP-границе всё равно работает **Jackson 3**, поэтому время покрывается двумя путями:
|
||
- Jackson-2 модуль **из либы** (`pcp_types_lib.utils.json.PcpTimeModule`) — явно зарегистрирован в
|
||
ручном маппере `CamundaJacksonConfig` (Camunda/внутренний парсинг); своего зеркала больше нет.
|
||
- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST HTTP message converters (MVC),
|
||
которые Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином
|
||
`tools.jackson.databind.JacksonModule` (`RestJacksonConfig`); `JacksonAutoConfiguration` собирает
|
||
все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Лежит локально, т.к. либа даёт
|
||
только Jackson-2 модуль; поведение обязано совпадать с библиотечным, правки синхронизировать.
|
||
|
||
Контракт слоя закреплён `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()` (воркеры выдачи/решений, `PlanQueryService`
|
||
cutoff, 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) переведены с naive `LocalDateTime` на `OffsetDateTime` в UTC →
|
||
на проводе `Z`. Домен/БД (`PlannedPlanEntity`/`CalculatedPlan`) остались naive `LocalDateTime` (=UTC);
|
||
конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён
|
||
синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше.
|
||
|
||
- **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib
|
||
получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — модулем из либы (Camunda,
|
||
Jackson 2) плюс локальное Jackson-3 зеркало для REST-границы MVC. Это включатель: дальнейший флип
|
||
продюсеров на `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-граница на Jackson 3** — теперь толерантна (см. 2bis). Добавлен Jackson-3
|
||
вариант модуля `config/PcpTimeModuleV3.kt` и его регистрация бином `JacksonModule`
|
||
(`config/RestJacksonConfig.kt`); входящий REST-JSON читается в обоих форматах (naive=UTC и `Z`).
|
||
Закреплено `PcpTimeModuleV3Test`.
|
||
|
||
### Миграция tgu на MVC
|
||
|
||
- **pcp-tgu-service: WebFlux → servlet/MVC.** Сервис не имел реального реактивного кода — JPA
|
||
блокирующая, контроллеры оборачивали блокирующие вызовы в `Mono.fromCallable{}.subscribeOn(
|
||
boundedElastic())`, WebClient-вызовы `.block()`-ались сразу. Это был единственный WebFlux-only
|
||
сервис флота. Переведён на MVC: подключён pcp-types-lib (тянет `starter-web` → тип приложения
|
||
servlet), `springdoc` → `webmvc-ui`, контроллеры возвращают значения напрямую (без `Mono`).
|
||
`starter-webflux` оставлен только ради WebClient. Побочный эффект для времени: убрано локальное
|
||
Jackson-2 зеркало (`config/PcpTimeModule.kt`) в пользу модуля из либы; Jackson-3 зеркало остаётся
|
||
(Boot 4 держит Jackson 3 на HTTP-границе и для MVC).
|
||
|
||
### Остаток (под-задачи)
|
||
|
||
- Обе Jackson-среды tgu толерантны; отдельных остатков по чтению времени нет. При миграции
|
||
оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта.
|
||
|
||
## 5. Правило для нового кода
|
||
|
||
- Frontend: только `utils/utcTime` (страж `utcTime.guard.test.ts` не даст просочиться нарушителю).
|
||
- Backend: UTC-`now`; на новых API предпочтителен явный `Z` (`OffsetDateTime`/`Instant`).
|
||
- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).
|