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:
-5
@@ -10,7 +10,6 @@ import io.camunda.client.api.JsonMapper as CamundaJsonMapper
|
||||
import io.camunda.client.impl.CamundaObjectMapper
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
|
||||
|
||||
@Configuration
|
||||
class CamundaJacksonConfig {
|
||||
@@ -25,10 +24,6 @@ class CamundaJacksonConfig {
|
||||
return JsonMapper.builder()
|
||||
.addModule(KotlinModule.Builder().build())
|
||||
.addModule(JavaTimeModule())
|
||||
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
|
||||
// Модуль из pcp-types-lib; покрывает Jackson-2 путь (этот ручной маппер для Camunda).
|
||||
// REST-границу (Jackson 3) покрывает PcpTimeModuleV3 (см. RestJacksonConfig).
|
||||
.addModule(PcpTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build()
|
||||
}
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package space.nstart.pcp_tgu_service.config
|
||||
|
||||
import tools.jackson.core.JsonParser
|
||||
import tools.jackson.core.JsonToken
|
||||
import tools.jackson.databind.DeserializationContext
|
||||
import tools.jackson.databind.ValueDeserializer
|
||||
import tools.jackson.databind.ext.javatime.deser.InstantDeserializer
|
||||
import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer
|
||||
import tools.jackson.databind.module.SimpleModule
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
/**
|
||||
* Слой толерантности времени для **Jackson 3** (`tools.jackson.*`).
|
||||
*
|
||||
* Близнец библиотечного `PcpTimeModule` (Jackson 2): то же поведение — читать время в обоих
|
||||
* форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию.
|
||||
* Нужен отдельно, потому что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST HTTP message
|
||||
* converters (MVC), а он не видит Jackson-2 модули. Регистрируется как бин
|
||||
* `tools.jackson.databind.JacksonModule` ([RestJacksonConfig]) — `JacksonAutoConfiguration` собирает
|
||||
* такие бины в общий `JsonMapper`.
|
||||
*
|
||||
* Канонический источник поведения — `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`;
|
||||
* правки синхронизировать. См. docs/standards/DATETIME_UTC.md.
|
||||
*/
|
||||
class PcpTimeModuleV3 : SimpleModule(NAME) {
|
||||
|
||||
init {
|
||||
addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializerV3())
|
||||
addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializerV3())
|
||||
addDeserializer(Instant::class.java, TolerantInstantDeserializerV3())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "PcpTimeModuleV3"
|
||||
}
|
||||
}
|
||||
|
||||
private class TolerantLocalDateTimeDeserializerV3 : ValueDeserializer<LocalDateTime>() {
|
||||
|
||||
private val default: ValueDeserializer<LocalDateTime> = LocalDateTimeDeserializer.INSTANCE
|
||||
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? {
|
||||
if (p.currentToken() == JsonToken.VALUE_STRING) {
|
||||
val text = p.string.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 TolerantOffsetDateTimeDeserializerV3 : ValueDeserializer<OffsetDateTime>() {
|
||||
|
||||
private val default: ValueDeserializer<OffsetDateTime> = InstantDeserializer.OFFSET_DATE_TIME
|
||||
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? {
|
||||
if (p.currentToken() == JsonToken.VALUE_STRING) {
|
||||
val text = p.string.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 TolerantInstantDeserializerV3 : ValueDeserializer<Instant>() {
|
||||
|
||||
private val default: ValueDeserializer<Instant> = InstantDeserializer.INSTANT
|
||||
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? {
|
||||
if (p.currentToken() == JsonToken.VALUE_STRING) {
|
||||
val text = p.string.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)
|
||||
}
|
||||
}
|
||||
+2
-19
@@ -1,21 +1,4 @@
|
||||
package space.nstart.pcp_tgu_service.config
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import tools.jackson.databind.JacksonModule
|
||||
|
||||
/**
|
||||
* Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3
|
||||
* `JsonMapper`, который Boot 4 использует для REST HTTP message converters (MVC).
|
||||
*
|
||||
* `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста
|
||||
* и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин
|
||||
* этого типа. Входящий REST-JSON читается толерантно к обоим форматам времени (naive=UTC и `Z`),
|
||||
* как и Jackson-2 путь Camunda ([CamundaJacksonConfig]). См. docs/standards/DATETIME_UTC.md.
|
||||
*/
|
||||
@Configuration
|
||||
class RestJacksonConfig {
|
||||
|
||||
@Bean
|
||||
fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3()
|
||||
}
|
||||
// Пустой — Jackson 3 регистрирует JavaTimeModule/KotlinModule автоматически через Boot-автоконфиг.
|
||||
// LocalDateTime сериализуется как ISO-строка без зоны при disable(WRITE_DATES_AS_TIMESTAMPS).
|
||||
|
||||
+5
-10
@@ -2,24 +2,19 @@ package space.nstart.pcp_tgu_service.dto
|
||||
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — UTC ISO с `Z`
|
||||
* (как [PlanResponse]); внутри домена это naive `LocalDateTime` (= UTC).
|
||||
*/
|
||||
/** «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — naive ISO без зоны. */
|
||||
data class ZrvWindowResponse(
|
||||
val startTime: OffsetDateTime,
|
||||
val endTime: OffsetDateTime,
|
||||
val startTime: LocalDateTime,
|
||||
val endTime: LocalDateTime,
|
||||
val kppId: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(window: CalculatedPlan): ZrvWindowResponse =
|
||||
ZrvWindowResponse(
|
||||
startTime = window.startTime.atOffset(ZoneOffset.UTC),
|
||||
endTime = window.endTime.atOffset(ZoneOffset.UTC),
|
||||
startTime = window.startTime,
|
||||
endTime = window.endTime,
|
||||
kppId = window.kppId
|
||||
)
|
||||
}
|
||||
|
||||
+11
-16
@@ -4,17 +4,14 @@ import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* DTO ответа REST API для плана.
|
||||
*
|
||||
* Времена ([startTime]/[endTime]) — `OffsetDateTime` в UTC и сериализуются как ISO-строка с явной
|
||||
* зоной `Z` (напр. `"2026-05-29T01:00:00Z"`), см. `docs/standards/DATETIME_UTC.md`. Внутреннее
|
||||
* хранение и домен (`PlannedPlanEntity`/`CalculatedPlan`) остаются naive `LocalDateTime` (= UTC);
|
||||
* конвертация в `Z` происходит только на границе API.
|
||||
* Времена ([startTime]/[endTime]) — naive `LocalDateTime`, сериализуются как ISO-строка без зоны
|
||||
* (напр. `"2026-05-29T01:00:00"`). Хранение, домен и API работают на одних настенных часах.
|
||||
*/
|
||||
data class PlanResponse(
|
||||
|
||||
@@ -24,11 +21,11 @@ data class PlanResponse(
|
||||
/** Идентификатор космического аппарата */
|
||||
val spacecraftId: String,
|
||||
|
||||
/** Время начала плана (UTC, ISO с `Z`). */
|
||||
val startTime: OffsetDateTime,
|
||||
/** Время начала плана (ISO без зоны). */
|
||||
val startTime: LocalDateTime,
|
||||
|
||||
/** Время окончания плана (UTC, ISO с `Z`). */
|
||||
val endTime: OffsetDateTime,
|
||||
/** Время окончания плана (ISO без зоны). */
|
||||
val endTime: LocalDateTime,
|
||||
|
||||
/** Идентификатор пункта закладки */
|
||||
val kppId: String,
|
||||
@@ -46,9 +43,8 @@ data class PlanResponse(
|
||||
PlanResponse(
|
||||
planId = plan.planId,
|
||||
spacecraftId = plan.spacecraftId,
|
||||
// naive LocalDateTime в БД = UTC → отдаём с явной зоной Z.
|
||||
startTime = plan.startTime.atOffset(ZoneOffset.UTC),
|
||||
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
|
||||
startTime = plan.startTime,
|
||||
endTime = plan.endTime,
|
||||
kppId = plan.kppId,
|
||||
status = plan.status
|
||||
)
|
||||
@@ -63,9 +59,8 @@ data class PlanResponse(
|
||||
PlanResponse(
|
||||
planId = deterministicPlanId(plan),
|
||||
spacecraftId = plan.spacecraftId,
|
||||
// naive LocalDateTime расчёта = UTC → отдаём с явной зоной Z.
|
||||
startTime = plan.startTime.atOffset(ZoneOffset.UTC),
|
||||
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
|
||||
startTime = plan.startTime,
|
||||
endTime = plan.endTime,
|
||||
kppId = plan.kppId,
|
||||
status = PlannedPlanStatus.PLANNED
|
||||
)
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package space.nstart.pcp_tgu_service.config
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import tools.jackson.databind.json.JsonMapper
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Слой толерантности времени на Jackson-3 пути (REST-кодеки WebFlux): [PcpTimeModuleV3] читает
|
||||
* время в обоих форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC. Близнец
|
||||
* библиотечного PcpTimeModuleTest на Jackson 2. См. docs/standards/DATETIME_UTC.md.
|
||||
*/
|
||||
class PcpTimeModuleV3Test {
|
||||
|
||||
private val mapper = JsonMapper.builder().addModule(PcpTimeModuleV3()).build()
|
||||
|
||||
@Test
|
||||
fun `LocalDateTime reads naive and Z as the same UTC wall-clock`() {
|
||||
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", LocalDateTime::class.java)
|
||||
val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", LocalDateTime::class.java)
|
||||
|
||||
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromNaive)
|
||||
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromZ)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LocalDateTime normalizes an explicit offset to UTC`() {
|
||||
// 01:00+03:00 == 28-го 22:00 UTC
|
||||
val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", LocalDateTime::class.java)
|
||||
|
||||
assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), fromOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OffsetDateTime reads naive as UTC and normalizes offset to UTC`() {
|
||||
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", OffsetDateTime::class.java)
|
||||
val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", OffsetDateTime::class.java)
|
||||
|
||||
assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), fromNaive)
|
||||
assertEquals(OffsetDateTime.of(2026, 5, 28, 22, 0, 0, 0, ZoneOffset.UTC), fromOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Instant reads naive(=UTC) and Z to the same moment`() {
|
||||
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", Instant::class.java)
|
||||
val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", Instant::class.java)
|
||||
|
||||
val expected = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC).toInstant()
|
||||
assertEquals(expected, fromNaive)
|
||||
assertEquals(expected, fromZ)
|
||||
}
|
||||
}
|
||||
+10
-14
@@ -3,19 +3,15 @@ package space.nstart.pcp_tgu_service.dto
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Контракт формата времени на проводе для [PlanResponse]: `OffsetDateTime` в UTC → ISO с явной зоной `Z`
|
||||
* (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля
|
||||
* (назад на naive `LocalDateTime`) или Jackson-конфига, которые поломали бы потребителей.
|
||||
*
|
||||
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
|
||||
* Контракт формата времени на проводе для [PlanResponse]: naive `LocalDateTime` → ISO без зоны
|
||||
* (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам.
|
||||
*/
|
||||
class PlanResponseContractTest {
|
||||
|
||||
@@ -23,20 +19,20 @@ class PlanResponseContractTest {
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
|
||||
@Test
|
||||
fun `serializes plan times as ISO with explicit Z zone`() {
|
||||
fun `serializes plan times as naive ISO without zone`() {
|
||||
val response = PlanResponse(
|
||||
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
spacecraftId = "25544",
|
||||
startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC),
|
||||
endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC),
|
||||
startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
|
||||
endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
|
||||
kppId = "KPP-1",
|
||||
status = PlannedPlanStatus.PLANNED
|
||||
)
|
||||
|
||||
val node = mapper.readTree(mapper.writeValueAsString(response))
|
||||
|
||||
assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText())
|
||||
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText())
|
||||
assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z")
|
||||
assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
|
||||
assertEquals("2026-05-29T02:00:00", node.get("endTime").asText())
|
||||
assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z")
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -39,10 +39,9 @@ class PlanQueryServiceTest {
|
||||
val result = service.getAllPlans(historyDays = 7)
|
||||
|
||||
assertEquals(2, result.size)
|
||||
// PlanResponse.startTime — OffsetDateTime(UTC); доменные времена naive LocalDateTime (= UTC).
|
||||
val persistedResponse = result.first { it.startTime == persisted.startTime.atOffset(ZoneOffset.UTC) }
|
||||
val persistedResponse = result.first { it.startTime == persisted.startTime }
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status)
|
||||
val projectedResponse = result.first { it.startTime == freshProjected.startTime.atOffset(ZoneOffset.UTC) }
|
||||
val projectedResponse = result.first { it.startTime == freshProjected.startTime }
|
||||
assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status)
|
||||
// Отсортировано по startTime.
|
||||
assertTrue(result[0].startTime <= result[1].startTime)
|
||||
|
||||
Reference in New Issue
Block a user