pcp: слой толерантности времени — читать naive(=UTC) и Z во всём флоте

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.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 22:35:50 +03:00
parent 240e12f442
commit 405f02aa86
10 changed files with 391 additions and 3 deletions
@@ -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()
}
@@ -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<LocalDateTime>() {
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<OffsetDateTime>() {
@Suppress("UNCHECKED_CAST")
private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer<OffsetDateTime>
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<Instant>() {
@Suppress("UNCHECKED_CAST")
private val default = InstantDeserializer.INSTANT as JsonDeserializer<Instant>
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)
}
}
@@ -0,0 +1 @@
space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
@@ -0,0 +1 @@
space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration
@@ -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<LdtHolder>("""{"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)
}
}