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:
@@ -22,6 +22,7 @@
|
|||||||
- добавлены или обновлены релевантные тесты;
|
- добавлены или обновлены релевантные тесты;
|
||||||
- не внесено несвязанных правок;
|
- не внесено несвязанных правок;
|
||||||
- время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`);
|
- время на 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`, либо пропуски явно объяснены.
|
- validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены.
|
||||||
|
|
||||||
### Дополнительно для BPM / Kafka / Zeebe / external integration изменений
|
### Дополнительно для BPM / Kafka / Zeebe / external integration изменений
|
||||||
|
|||||||
@@ -32,9 +32,50 @@
|
|||||||
## 2. Эталоны переиспользования
|
## 2. Эталоны переиспользования
|
||||||
|
|
||||||
- Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования.
|
- 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()` — корректный образец.
|
через `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. Состояние сервисов (срез аудита)
|
## 3. Состояние сервисов (срез аудита)
|
||||||
|
|
||||||
Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC),
|
Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC),
|
||||||
@@ -93,8 +134,22 @@ mission-planing) используют тот же дефолтный Jackson-к
|
|||||||
конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён
|
конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён
|
||||||
синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше.
|
синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше.
|
||||||
|
|
||||||
Следующие кандидаты на миграцию (по согласованию, по сервису): остальные naive-DTO ui-service,
|
- **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib
|
||||||
ballistics, complex-mission, dynamic-plan, slots, coverage-scheme.
|
получают его авто-конфигурацией/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. Правило для нового кода
|
## 5. Правило для нового кода
|
||||||
|
|
||||||
|
|||||||
+23
@@ -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()
|
||||||
|
}
|
||||||
+124
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration
|
||||||
+81
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
|||||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
|
||||||
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@@ -28,6 +29,9 @@ class JacksonConfig {
|
|||||||
// )
|
// )
|
||||||
mapper.registerModule(JavaTimeModule())
|
mapper.registerModule(JavaTimeModule())
|
||||||
.registerKotlinModule()
|
.registerKotlinModule()
|
||||||
|
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
|
||||||
|
// Этот маппер создаётся вручную и не подхватывает модуль ни через Boot-бин, ни через SPI.
|
||||||
|
.registerModule(PcpTimeModule())
|
||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
|
|
||||||
return mapper
|
return mapper
|
||||||
|
|||||||
+4
@@ -24,6 +24,10 @@ class CamundaJacksonConfig {
|
|||||||
return JsonMapper.builder()
|
return JsonMapper.builder()
|
||||||
.addModule(KotlinModule.Builder().build())
|
.addModule(KotlinModule.Builder().build())
|
||||||
.addModule(JavaTimeModule())
|
.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)
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
+94
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user