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:
Дмитрий Соловьев
2026-06-04 00:03:52 +03:00
parent 0e534c5bd5
commit 3638afdb85
57 changed files with 230 additions and 1063 deletions
@@ -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()
}
@@ -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)
}
}
@@ -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).
@@ -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
)
}
@@ -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
)
@@ -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)
}
}
@@ -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")
}
}
@@ -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)