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
@@ -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()
}
@@ -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<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
try {
return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()
} catch (_: DateTimeParseException) {
// naive-строка без зоны — трактуем как UTC
}
}
return default.deserialize(p, ctxt)
}
}
private 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) {
return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC)
}
}
return default.deserialize(p, ctxt)
}
}
private 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) {
return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC)
}
}
return default.deserialize(p, ctxt)
}
}