From 405f02aa86dc0a1f59e81a4c2431e28cc366cd15 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:35:50 +0300 Subject: [PATCH] =?UTF-8?q?pcp:=20=D1=81=D0=BB=D0=BE=D0=B9=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D0=B5=D1=80=D0=B0=D0=BD=D1=82=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=E2=80=94?= =?UTF-8?q?=20=D1=87=D0=B8=D1=82=D0=B0=D1=82=D1=8C=20naive(=3DUTC)=20?= =?UTF-8?q?=D0=B8=20Z=20=D0=B2=D0=BE=20=D0=B2=D1=81=D1=91=D0=BC=20=D1=84?= =?UTF-8?q?=D0=BB=D0=BE=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/standards/CHECKLIST.md | 1 + docs/standards/DATETIME_UTC.md | 61 ++++++++- .../PcpTimeJacksonAutoConfiguration.kt | 23 ++++ .../pcp_types_lib/utils/json/PcpTimeModule.kt | 124 ++++++++++++++++++ .../com.fasterxml.jackson.databind.Module | 1 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../utils/json/PcpTimeModuleTest.kt | 81 ++++++++++++ .../space/nstart/pcp/complan/JacksonConfig.kt | 4 + .../config/CamundaJacksonConfig.kt | 4 + .../pcp_tgu_service/config/PcpTimeModule.kt | 94 +++++++++++++ 10 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt create mode 100644 libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module create mode 100644 libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md index 3be28ae..88e0b19 100644 --- a/docs/standards/CHECKLIST.md +++ b/docs/standards/CHECKLIST.md @@ -22,6 +22,7 @@ - добавлены или обновлены релевантные тесты; - не внесено несвязанных правок; - время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`); +- чтение времени на бэке толерантно к обоим форматам (naive=UTC и `Z`) — модуль наследуется из `pcp-types-lib` (`PcpTimeModule`) авто-конфигурацией; ручной `ObjectMapper`/не-pcp-types-lib сервис регистрирует модуль явно; - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index 0e18509..4cb52d3 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -32,9 +32,50 @@ ## 2. Эталоны переиспользования - Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования. -- Backend: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход +- 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.** Он не зависит от 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), @@ -93,8 +134,22 @@ mission-planing) используют тот же дефолтный Jackson-к конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. -Следующие кандидаты на миграцию (по согласованию, по сервису): остальные naive-DTO ui-service, -ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. +- **Слой толерантности (`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. Правило для нового кода diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt new file mode 100644 index 0000000..6d1ae8f --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.pcp_types_lib.configuration + +import com.fasterxml.jackson.databind.Module +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.context.annotation.Bean +import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule + +/** + * Регистрирует [PcpTimeModule] в Boot-managed `ObjectMapper` каждого сервиса, который подключает + * pcp-types-lib. Spring Boot собирает все бины типа [Module] из контекста в авто-конфигурируемый + * маппер, поэтому отдельный `@Bean` доезжает до всех сервисов без явного component-scan пакета либы. + * + * Ручные мапперы (вызывающие `findAndRegisterModules()`) подхватывают модуль через Jackson SPI — + * см. META-INF/services/com.fasterxml.jackson.databind.Module. + * + * Слой толерантности — см. [PcpTimeModule] и docs/standards/DATETIME_UTC.md. + */ +@AutoConfiguration +class PcpTimeJacksonAutoConfiguration { + + @Bean + fun pcpTimeModule(): Module = PcpTimeModule() +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt new file mode 100644 index 0000000..3ad19eb --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt @@ -0,0 +1,124 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +/** + * Слой толерантности времени для всей группы `pcp` (бэковый аналог фронтового `parseUtc`). + * + * Любой потребитель с этим модулем читает время в **обоих** форматах на проводе: + * naive ISO без зоны (исторический формат, трактуется как UTC по соглашению + * docs/standards/DATETIME_UTC.md) и ISO с явной зоной/`Z` (формат после миграции). + * Все значения нормализуются к UTC. + * + * Зачем: микросервисы деплоятся независимо. Если включить толерантность во всём флоте, + * продюсер можно перевести `LocalDateTime` → `OffsetDateTime` (на проводе появляется `Z`) + * в любом порядке — потребитель не упадёт на парсинге офсета. Без этого слоя миграция + * формата требовала бы синхронного деплоя продюсера и всех потребителей. + * + * Модуль трогает **только десериализацию** (чтение). Формат записи определяется типом поля + * (`LocalDateTime` → naive, `OffsetDateTime`/`Instant` → `Z`) и здесь не меняется, чтобы + * подключение слоя само по себе не сдвигало ни один контракт. + * + * Подключается через Spring Boot auto-configuration ([PcpTimeJacksonAutoConfiguration]) для + * Boot-managed `ObjectMapper` и через Jackson SPI + * (`META-INF/services/com.fasterxml.jackson.databind.Module`) для ручных мапперов, + * вызывающих `findAndRegisterModules()`. + */ +class PcpTimeModule : SimpleModule(NAME) { + + init { + addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer()) + addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer()) + addDeserializer(Instant::class.java, TolerantInstantDeserializer()) + } + + companion object { + const val NAME = "PcpTimeModule" + } +} + +/** + * Читает [LocalDateTime] из обоих форматов: строка с зоной/`Z` нормализуется к UTC и теряет зону, + * naive-строка парсится как есть (= UTC). Нестроковые токены (массивы/таймстемпы) делегируются + * штатному [LocalDateTimeDeserializer], чтобы не сломать прочие представления. + */ +class TolerantLocalDateTimeDeserializer : JsonDeserializer() { + + private val default = LocalDateTimeDeserializer.INSTANCE + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + tryParseToUtc(text)?.let { return it.toLocalDateTime() } + } + return default.deserialize(p, ctxt) + } + + /** Возвращает момент в UTC, если строка несёт зону/офсет; иначе null (строка naive). */ + private fun tryParseToUtc(text: String): OffsetDateTime? = + try { + OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + null + } +} + +/** + * Читает [OffsetDateTime] из обоих форматов: naive-строка получает зону UTC, + * строка с зоной приводится к UTC. + */ +class TolerantOffsetDateTimeDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + // naive-строка без зоны — трактуем как UTC + return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} + +/** + * Читает [Instant] из обоих форматов: строка с `Z`/офсетом — штатно, naive-строка трактуется + * как момент в UTC. + */ +class TolerantInstantDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.INSTANT as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).toInstant() + } catch (_: DateTimeParseException) { + // naive-строка без зоны — трактуем как UTC + return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 0000000..6e96a8a --- /dev/null +++ b/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1 @@ +space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..32f1b41 --- /dev/null +++ b/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt new file mode 100644 index 0000000..c14c4fe --- /dev/null +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt @@ -0,0 +1,81 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +/** + * Контракт слоя толерантности времени ([PcpTimeModule]): любой потребитель читает время + * в обоих форматах на проводе (naive=UTC и `Z`/офсет), всё нормализуется к UTC. + * Это и есть то, что делает безопасным пер-сервисный флип продюсера на `Z` + * без синхронного деплоя (см. docs/standards/DATETIME_UTC.md). + */ +class PcpTimeModuleTest { + + private val mapper = jacksonObjectMapper().registerModule(PcpTimeModule()) + + private data class LdtHolder(val t: LocalDateTime) + private data class OdtHolder(val t: OffsetDateTime) + private data class InstantHolder(val t: Instant) + + @Test + fun `LocalDateTime field reads naive and Z as the same UTC moment`() { + val naive: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") + val zoned: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + + val expected = LocalDateTime.of(2026, 5, 29, 1, 0, 0) + assertEquals(expected, naive.t) + assertEquals(expected, zoned.t, "Z-строка должна дать тот же UTC-момент, что naive") + } + + @Test + fun `LocalDateTime field converts explicit offset to UTC`() { + // +03:00 01:00 == 22:00 UTC предыдущего дня + val moscow: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00+03:00"}""") + + assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), moscow.t) + } + + @Test + fun `LocalDateTime field accepts Z without throwing (the regression the layer prevents)`() { + // Штатный LocalDateTimeDeserializer кинул бы исключение на офсете/Z. + assertDoesNotThrow { + mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + } + } + + @Test + fun `OffsetDateTime field reads naive as UTC and normalizes offset to UTC`() { + val naive: OdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") + val moscow: OdtHolder = mapper.readValue("""{"t":"2026-05-29T04:00:00+03:00"}""") + + assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), naive.t) + assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), moscow.t) + } + + @Test + fun `Instant field reads naive as UTC and Z as is`() { + val naive: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") + val zoned: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + + assertEquals(Instant.parse("2026-05-29T01:00:00Z"), naive.t) + assertEquals(Instant.parse("2026-05-29T01:00:00Z"), zoned.t) + } + + @Test + fun `module is auto-discovered via Jackson SPI (findAndRegisterModules)`() { + // Подтверждает, что META-INF/services/com.fasterxml.jackson.databind.Module работает: + // ручной маппер с findAndRegisterModules() получает толерантность без явной регистрации. + val spiMapper = jacksonObjectMapper().findAndRegisterModules() + + val holder: LdtHolder = spiMapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + + assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), holder.t) + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt index eb0667d..b7e6727 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule @Configuration @@ -28,6 +29,9 @@ class JacksonConfig { // ) mapper.registerModule(JavaTimeModule()) .registerKotlinModule() + // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. + // Этот маппер создаётся вручную и не подхватывает модуль ни через Boot-бин, ни через SPI. + .registerModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) return mapper 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 ecc1537..25d4278 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 @@ -24,6 +24,10 @@ class CamundaJacksonConfig { return JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .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. + .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/PcpTimeModule.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt new file mode 100644 index 0000000..e359d09 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt @@ -0,0 +1,94 @@ +package space.nstart.pcp_tgu_service.config + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +/** + * Локальное зеркало слоя толерантности времени из libs/pcp-types-lib + * (`space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`). + * + * Канонический источник — там; здесь копия, потому что pcp-tgu-service работает на WebFlux, + * а pcp-types-lib тянет `spring-boot-starter-web` (MVC/Tomcat) и переключил бы тип приложения + * на сервлетный. Поведение обязано совпадать с библиотечным; правки синхронизировать. + * + * Любой потребитель с этим модулем читает время в обоих форматах на проводе (naive=UTC и `Z`/офсет), + * нормализуя к UTC. Трогает только десериализацию. См. docs/standards/DATETIME_UTC.md. + */ +class PcpTimeModule : SimpleModule(NAME) { + + init { + addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer()) + addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer()) + addDeserializer(Instant::class.java, TolerantInstantDeserializer()) + } + + companion object { + const val NAME = "PcpTimeModule" + } +} + +private class TolerantLocalDateTimeDeserializer : JsonDeserializer() { + + private val default = LocalDateTimeDeserializer.INSTANCE + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.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 TolerantOffsetDateTimeDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.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 TolerantInstantDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.INSTANT as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.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) + } +}