pcp: откат UTC-слоя — сквозной naive LocalDateTime без зоны на проводе
Возврат к модели «настенных часов»: одна ISO-строка без зоны
("2026-05-29T01:00:00") от БД до экрана, никто не привязывает её к
часовому поясу, сдвигаться нечему.
Бэкенд — снять зону с границы API:
- DTO OffsetDateTime/Instant -> LocalDateTime и убрана конвертация
atOffset(ZoneOffset.UTC)/toUtcOffset() в фабриках и мапперах:
tgu PlanResponse/NewChainDTOs/TguPlanningDTO, request RequestApiDtos,
complex ComplexPlanRun*DTO, dynamic ObservationParametersDebugDTO.
- Удалён толерантный Jackson-слой: PcpTimeModule(+autoconfig, ServiceLoader,
imports), PcpTimeModuleV3, LocalDateTimeTimeZoneSerializer; снята их
регистрация в Camunda/Rest/dynamic JacksonConfig. Штатный JSR-310
(JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS=false) оставлен.
- Kafka-конверт KafkaMessage.time сериализуется ISO без офсета.
- mission-planing Mode/SurveyMode/DropMode entity и репозитории/сервис
переведены на LocalDateTime; колонки БД остаются timestamptz (Hibernate
мапит на naive без миграции — прецедент MissionEntity/PlanEntity).
- RequestServiceClient (dynamic) синхронизирован по типу с request-service.
Анти-регрессия: LocalDateTime.now(ZoneOffset.UTC) для серверных штампов
оставлен (выбор настенных часов UTC, не зона на проводе).
Фронт — снять офсет с egress: toUtcIso/фильтры отдают naive без Z
(после удаления толерантного десериализатора штатный парсер падал бы на Z).
Дисплейные хелперы (показ «как пришло», без сдвига) сохранены.
Тесты: контрактные ждут naive ISO; удалены UTC-артефакты (нормализация
офсетов) и тесты прослоек. Удалён docs/standards/DATETIME_UTC.md.
Операционно перед выкатом: сервисы катить согласованно; Kafka-топики с
ранее опубликованным офсетом +00:00 дренировать/проиграть.
This commit is contained in:
-23
@@ -1,23 +0,0 @@
|
||||
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()
|
||||
}
|
||||
+4
-4
@@ -3,7 +3,7 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Schema(
|
||||
description = "Базовая модель режима",
|
||||
@@ -28,7 +28,7 @@ sealed interface ModeResponseDTO {
|
||||
val planId: Long
|
||||
|
||||
@get:Schema(description = "Время начала режима")
|
||||
val timeStart: OffsetDateTime?
|
||||
val timeStart: LocalDateTime?
|
||||
|
||||
@get:Schema(description = "Номер витка")
|
||||
val revolution: Long
|
||||
@@ -41,7 +41,7 @@ sealed interface ModeResponseDTO {
|
||||
data class SurveyModeResponseDTO(
|
||||
override val id: Long = 0,
|
||||
override val planId: Long = 0,
|
||||
override val timeStart: OffsetDateTime? = null,
|
||||
override val timeStart: LocalDateTime? = null,
|
||||
override val revolution: Long = 0,
|
||||
override val type: String = "SURVEY",
|
||||
val status: String = "",
|
||||
@@ -60,7 +60,7 @@ data class SurveyModeResponseDTO(
|
||||
data class DropModeResponseDTO(
|
||||
override val id: Long = 0,
|
||||
override val planId: Long = 0,
|
||||
override val timeStart: OffsetDateTime? = null,
|
||||
override val timeStart: LocalDateTime? = null,
|
||||
override val revolution: Long = 0,
|
||||
override val type: String = "DROP",
|
||||
val station: String? = "",
|
||||
|
||||
+3
-7
@@ -2,8 +2,6 @@ package space.nstart.pcp.pcp_types_lib.message
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat
|
||||
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.*
|
||||
@@ -21,11 +19,9 @@ 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)
|
||||
// Время конверта — naive LocalDateTime, штампуется по настенным часам UTC (now(ZoneOffset.UTC)),
|
||||
// сериализуется как ISO без зоны.
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS")
|
||||
val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
val dataContentType = "application/json"
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package space.nstart.pcp.pcp_types_lib.utils.json
|
||||
|
||||
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.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 {
|
||||
val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx")
|
||||
}
|
||||
|
||||
override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) {
|
||||
p1.writeString(p0?.atOffset(ZoneOffset.UTC)?.format(fmt))
|
||||
}
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
|
||||
-1
@@ -1 +0,0 @@
|
||||
space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user