pcp-types-lib: Kafka-конверт time в UTC (фикс сдвига пояса)

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: баг перенесён в «Исправленные».
This commit is contained in:
Дмитрий Соловьев
2026-06-02 22:48:13 +03:00
parent 405f02aa86
commit 84e70350b9
4 changed files with 73 additions and 13 deletions
@@ -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<out T> (
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"
@@ -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<LocalDateTime?>() {
companion object {
@@ -14,9 +21,6 @@ class LocalDateTimeTimeZoneSerializer: JsonSerializer<LocalDateTime?>() {
}
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))
}
}
@@ -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)
}
}