From 84e70350b9cc3b314b9266975dea4ec1f9ea84a2 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:48:13 +0300 Subject: [PATCH] =?UTF-8?q?pcp-types-lib:=20Kafka-=D0=BA=D0=BE=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=82=20time=20=D0=B2=20UTC=20(=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=20=D1=81=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=8F=D1=81=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KafkaMessage.time: LocalDateTime.now() -> now(ZoneOffset.UTC); LocalDateTimeTimeZoneSerializer: atZone(systemDefault()) -> atOffset(UTC). Было: локальное now() + systemDefault давали на проводе "...+03:00", и потребитель (паттерн ...SSS[xxx] в LocalDateTime) дропал зону -> сдвиг на пояс. Стало: "...+00:00" — инстант тот же, но стенные часы совпадают с UTC, сдвиг ушёл. Тест LocalDateTimeTimeZoneSerializerTest: UTC-офсет без сдвига + round-trip. DATETIME_UTC.md: баг перенесён в «Исправленные». --- docs/standards/DATETIME_UTC.md | 18 ++++--- .../pcp/pcp_types_lib/message/KafkaMessage.kt | 6 ++- .../json/LocalDateTimeTimeZoneSerializer.kt | 14 ++++-- .../LocalDateTimeTimeZoneSerializerTest.kt | 48 +++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index 4cb52d3..0fc7bc5 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -141,15 +141,19 @@ mission-planing) используют тот же дефолтный Jackson-к После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные 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`. + +### Остаток (под-задачи) -- **`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 - вариант модуля. + вариант модуля + регистрация в кодеках WebFlux. ## 5. Правило для нового кода diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt index 0e55531..55a7589 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.databind.annotation.JsonSerialize import space.nstart.pcp.pcp_types_lib.utils.json.LocalDateTimeTimeZoneSerializer import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.* @@ -20,9 +21,12 @@ class KafkaMessage ( val id = UUID.randomUUID().toString() var source = "Example" + // Время конверта в UTC: naive LocalDateTime трактуется как UTC (см. docs/standards/DATETIME_UTC.md), + // сериализатор помечает его офсетом +00:00. Раньше now() + systemDefault давали локальные часы и + // несовпадение момента у потребителей, дропающих зону. @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) - val time: LocalDateTime = LocalDateTime.now() + val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val dataContentType = "application/json" val specVersion = "1.0" diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt index 42b54ad..65b5373 100755 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt @@ -4,9 +4,16 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.SerializerProvider import java.time.LocalDateTime -import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeFormatter +/** + * Сериализует naive [LocalDateTime] как момент в **UTC** (офсет `+00:00`). + * По соглашению группы `pcp` naive LocalDateTime = UTC (см. docs/standards/DATETIME_UTC.md), + * поэтому зона навешивается через [ZoneOffset.UTC], а не через пояс машины. + * Прежняя реализация (`ZoneId.systemDefault()`) помечала те же часы локальным офсетом, из-за чего + * потребитель, дропающий зону, получал сдвиг на пояс. + */ class LocalDateTimeTimeZoneSerializer: JsonSerializer() { companion object { @@ -14,9 +21,6 @@ class LocalDateTimeTimeZoneSerializer: JsonSerializer() { } override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) { - - //todo: проверить корректность зоны по нормальным правилам -// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt)) - p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt)) + p1.writeString(p0?.atOffset(ZoneOffset.UTC)?.format(fmt)) } } \ No newline at end of file diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt new file mode 100644 index 0000000..69de0d6 --- /dev/null +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.databind.annotation.JsonSerialize +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.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +/** + * Контракт сериализатора времени Kafka-конверта ([LocalDateTimeTimeZoneSerializer]): + * naive LocalDateTime помечается офсетом UTC (`+00:00`), часы не сдвигаются. + * Закрывает баг прежней реализации через `ZoneId.systemDefault()` + * (см. docs/standards/DATETIME_UTC.md). + */ +class LocalDateTimeTimeZoneSerializerTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + + // Зеркалит аннотации реального поля KafkaMessage.time (паттерн с опциональным офсетом). + private data class Holder( + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") + @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) + val t: LocalDateTime + ) + + @Test + fun `serializes naive LocalDateTime with UTC offset, no wall-clock shift`() { + val json = mapper.writeValueAsString(Holder(LocalDateTime.of(2026, 5, 29, 1, 0, 0))) + + assertTrue( + json.contains("2026-05-29T01:00:00.000+00:00"), + "ожидался UTC-офсет без сдвига часов, получено: $json" + ) + } + + @Test + fun `round-trips through the tolerance layer to the same UTC moment`() { + val original = LocalDateTime.of(2026, 5, 29, 1, 0, 0) + + val json = mapper.writeValueAsString(Holder(original)) + val back: Holder = mapper.readValue(json) + + assertEquals(original, back.t) + } +}