From cde4ffa9b6ed526113497f69c46cea27b50cf16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 22:57:49 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-service:=20=D1=82=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D1=82=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2?= =?UTF-8?q?=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=BD=D0=B0=20Jackson-3?= =?UTF-8?q?=20=D0=BF=D1=83=D1=82=D0=B8=20(REST-=D0=BA=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D0=B8=20WebFlux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/standards/DATETIME_UTC.md | 30 ++++-- .../config/CamundaJacksonConfig.kt | 2 +- .../pcp_tgu_service/config/PcpTimeModuleV3.kt | 93 +++++++++++++++++++ .../config/WebFluxJacksonConfig.kt | 22 +++++ .../config/PcpTimeModuleV3Test.kt | 55 +++++++++++ 5 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index 0fc7bc5..c457f42 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -67,11 +67,15 @@ **Особый случай — 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 вариант модуля + регистрация в -кодеках. +сервлетный). Поэтому в tgu лежат **два локальных зеркала** (поведение обязано совпадать с +библиотечным, правки синхронизировать), покрывающих обе Jackson-среды сервиса: +- `config/PcpTimeModule.kt` (Jackson 2) — зарегистрирован в `CamundaJacksonConfig`, покрывает + Camunda/внутренний парсинг; +- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST-кодеки WebFlux, которые + Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином + `tools.jackson.databind.JacksonModule` (`WebFluxJacksonConfig`); `JacksonAutoConfiguration` + собирает все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Тем самым входящий + REST-JSON tgu тоже читается толерантно к обоим форматам. Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент, плюс проверка авто-подхвата через SPI. @@ -135,8 +139,9 @@ mission-planing) используют тот же дефолтный Jackson-к синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. - **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib - получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — локальным зеркалом (Jackson-2 - путь). Это включатель: дальнейший флип продюсеров на `Z` больше не требует синхронного деплоя. + получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — двумя локальными зеркалами + (Jackson-2 для Camunda и Jackson-3 для REST-кодеков WebFlux). Это включатель: дальнейший флип + продюсеров на `Z` больше не требует синхронного деплоя. После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. @@ -150,10 +155,17 @@ ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. `…+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`. + ### Остаток (под-задачи) -- **pcp-tgu-service REST-codec на Jackson 3** — ещё не толерантен (см. 2bis); под-задача на Jackson-3 - вариант модуля + регистрация в кодеках WebFlux. +- Обе Jackson-среды tgu теперь толерантны; отдельных остатков по чтению времени нет. При миграции + оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта. ## 5. Правило для нового кода diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt index 25d4278..fb8da9c 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt @@ -26,7 +26,7 @@ class CamundaJacksonConfig { .addModule(JavaTimeModule()) // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. // Покрывает Jackson-2 путь (Camunda/внутренний парсинг). REST-codec на Jackson 3 - // (WebFlux auto-config) — отдельный tgu-follow-up в DATETIME_UTC.md. + // (WebFlux auto-config) покрыт близнецом PcpTimeModuleV3 (см. WebFluxJacksonConfig). .addModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build() diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt new file mode 100644 index 0000000..316b836 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt @@ -0,0 +1,93 @@ +package space.nstart.pcp_tgu_service.config + +import tools.jackson.core.JsonParser +import tools.jackson.core.JsonToken +import tools.jackson.databind.DeserializationContext +import tools.jackson.databind.ValueDeserializer +import tools.jackson.databind.ext.javatime.deser.InstantDeserializer +import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer +import tools.jackson.databind.module.SimpleModule +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +/** + * Слой толерантности времени для **Jackson 3** (`tools.jackson.*`). + * + * Близнец [PcpTimeModule] (Jackson 2): то же поведение — читать время в обоих форматах на проводе + * (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. Нужен отдельно, потому + * что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST-кодеков WebFlux, а он не видит + * Jackson-2 модули. Регистрируется как бин `tools.jackson.databind.JacksonModule` + * ([WebFluxJacksonConfig]) — `JacksonAutoConfiguration` собирает такие бины в общий `JsonMapper`. + * + * Канонический источник поведения — `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`; + * правки синхронизировать. См. docs/standards/DATETIME_UTC.md. + */ +class PcpTimeModuleV3 : SimpleModule(NAME) { + + init { + addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializerV3()) + addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializerV3()) + addDeserializer(Instant::class.java, TolerantInstantDeserializerV3()) + } + + companion object { + const val NAME = "PcpTimeModuleV3" + } +} + +private class TolerantLocalDateTimeDeserializerV3 : ValueDeserializer() { + + private val default: ValueDeserializer = LocalDateTimeDeserializer.INSTANCE + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.string.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + } catch (_: DateTimeParseException) { + // naive-строка без зоны — трактуем как UTC + } + } + return default.deserialize(p, ctxt) + } +} + +private class TolerantOffsetDateTimeDeserializerV3 : ValueDeserializer() { + + private val default: ValueDeserializer = InstantDeserializer.OFFSET_DATE_TIME + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.string.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} + +private class TolerantInstantDeserializerV3 : ValueDeserializer() { + + private val default: ValueDeserializer = InstantDeserializer.INSTANT + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.string.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).toInstant() + } catch (_: DateTimeParseException) { + return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt new file mode 100644 index 0000000..343f848 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import tools.jackson.databind.JacksonModule + +/** + * Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3 + * `JsonMapper`, который Boot 4 использует для REST-кодеков WebFlux. + * + * `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста + * и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин + * этого типа. Закрывает tgu-follow-up из docs/standards/DATETIME_UTC.md: входящий REST-JSON теперь + * читается толерантно к обоим форматам времени (naive=UTC и `Z`), как и Jackson-2 путь + * ([CamundaJacksonConfig]). + */ +@Configuration +class WebFluxJacksonConfig { + + @Bean + fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3() +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt new file mode 100644 index 0000000..4679474 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt @@ -0,0 +1,55 @@ +package space.nstart.pcp_tgu_service.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tools.jackson.databind.json.JsonMapper +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +/** + * Слой толерантности времени на Jackson-3 пути (REST-кодеки WebFlux): [PcpTimeModuleV3] читает + * время в обоих форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC. Близнец + * библиотечного PcpTimeModuleTest на Jackson 2. См. docs/standards/DATETIME_UTC.md. + */ +class PcpTimeModuleV3Test { + + private val mapper = JsonMapper.builder().addModule(PcpTimeModuleV3()).build() + + @Test + fun `LocalDateTime reads naive and Z as the same UTC wall-clock`() { + val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", LocalDateTime::class.java) + val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", LocalDateTime::class.java) + + assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromNaive) + assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromZ) + } + + @Test + fun `LocalDateTime normalizes an explicit offset to UTC`() { + // 01:00+03:00 == 28-го 22:00 UTC + val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", LocalDateTime::class.java) + + assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), fromOffset) + } + + @Test + fun `OffsetDateTime reads naive as UTC and normalizes offset to UTC`() { + val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", OffsetDateTime::class.java) + val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", OffsetDateTime::class.java) + + assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), fromNaive) + assertEquals(OffsetDateTime.of(2026, 5, 28, 22, 0, 0, 0, ZoneOffset.UTC), fromOffset) + } + + @Test + fun `Instant reads naive(=UTC) and Z to the same moment`() { + val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", Instant::class.java) + val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", Instant::class.java) + + val expected = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC).toInstant() + assertEquals(expected, fromNaive) + assertEquals(expected, fromZ) + } +}