pcp: единообразие времени (UTC) по группе сервисов

Фронт (pcp-tgu-ops-ui):
- тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) /
  toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc.

Бэкенд:
- UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам
  (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах.
- Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе):
  - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime);
  - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime);
  - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC);
  - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()).
- Контрактные сериализационные тесты формата на проводе (naive без зоны / Z):
  PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui).
- KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ).

Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z),
docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
Дмитрий Соловьев
2026-06-02 21:51:38 +03:00
parent ac2220d063
commit 260583b134
84 changed files with 760 additions and 166 deletions
@@ -10,6 +10,7 @@ import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
@Table(name = "dynamic_plan_route")
@@ -31,10 +32,10 @@ class DynamicPlanRouteEntity(
var satelliteId: Long = 0,
@Column(name = "start_time", nullable = false)
var startTime: LocalDateTime = LocalDateTime.now(),
var startTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "end_time", nullable = false)
var endTime: LocalDateTime = LocalDateTime.now(),
var endTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "duration", nullable = false)
var duration: Double = 0.0,
@@ -10,6 +10,7 @@ import space.nstart.pcp.complan.types.DynamicPlanCalculationMode
import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode
import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
@Table(name = "dynamic_plan_run")
@@ -47,7 +48,7 @@ class DynamicPlanRunEntity(
var filterCoveredRoutes: Boolean = false,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
@Column(name = "started_at")
var startedAt: LocalDateTime? = null,
@@ -25,6 +25,7 @@ import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import space.nstart.pcp.complan.types.IntervalItemDTO
import space.nstart.pcp.complan.types.SurveyContour
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
import java.util.concurrent.ExecutorService
import kotlin.math.roundToLong
@@ -59,7 +60,7 @@ class DynamicPlanRunService(
calculationMode = request.calculationMode,
revolutionMode = request.revolutionMode,
filterCoveredRoutes = request.filterCoveredRoutes,
createdAt = LocalDateTime.now()
createdAt = LocalDateTime.now(ZoneOffset.UTC)
)
)
} ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create calculation run")
@@ -135,7 +136,7 @@ class DynamicPlanRunService(
return@execute 0
}
val finishedAt = LocalDateTime.now()
val finishedAt = LocalDateTime.now(ZoneOffset.UTC)
runs.forEach { run ->
run.status = DynamicPlanRunStatus.FAILED
run.finishedAt = finishedAt
@@ -171,7 +172,7 @@ class DynamicPlanRunService(
transactionTemplate.execute {
val run = runRepository.findById(runId).orElse(null) ?: return@execute null
run.status = DynamicPlanRunStatus.RUNNING
run.startedAt = LocalDateTime.now()
run.startedAt = LocalDateTime.now(ZoneOffset.UTC)
run.errorMessage = null
runRepository.save(run)
run.toRequest()
@@ -182,7 +183,7 @@ class DynamicPlanRunService(
val run = runRepository.findById(runId).orElseThrow()
val result = output.result
run.status = DynamicPlanRunStatus.COMPLETED
run.finishedAt = LocalDateTime.now()
run.finishedAt = LocalDateTime.now(ZoneOffset.UTC)
run.durationMs = result.durationMs
run.resultStatus = result.status.name
run.routesCount = result.routesCount
@@ -200,7 +201,7 @@ class DynamicPlanRunService(
transactionTemplate.execute {
val run = runRepository.findById(runId).orElseThrow()
run.status = DynamicPlanRunStatus.FAILED
run.finishedAt = LocalDateTime.now()
run.finishedAt = LocalDateTime.now(ZoneOffset.UTC)
run.errorMessage = error.message ?: error::class.java.simpleName
runRepository.save(run)
}
@@ -8,6 +8,7 @@ import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder
import space.nstart.pcp.complan.types.RequestItemDTO
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
/** Получение заявок из pcp-request-service /v1 API. */
@@ -43,8 +44,10 @@ class RequestServiceClient {
id = id,
name = name,
geometry = geometry,
intervalBegin = beginDateTime.toLocalDateTime(),
intervalEnd = endDateTime.toLocalDateTime(),
// Нормализуем к UTC: pcp-request-service шлёт `Z`, но голый toLocalDateTime() уехал бы
// при любом ненулевом офсете. Все naive LocalDateTime в системе — UTC (docs/standards/DATETIME_UTC.md).
intervalBegin = beginDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(),
intervalEnd = endDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(),
resolution = resolution(),
importance = importance,
)
@@ -6,6 +6,7 @@ import org.locationtech.jts.geom.LineString
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Класс космического аппарата**
@@ -17,9 +18,9 @@ class SpaceCraft (
val flightLine : MutableList<FlightLineDTO> = mutableListOf()
/**Время начала планирования аппарата*/
var timeStart: LocalDateTime = LocalDateTime.now()
var timeStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
/**Время конца планирования аппарата*/
var timeStop : LocalDateTime = LocalDateTime.now()
var timeStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
/**
* Создание линий полета восходящего и нисходящего витка
* @param geomFactory фабрика геометрии
@@ -62,9 +62,29 @@ class RequestServiceClientTest {
assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") })
}
@Test
fun `getRequest normalizes non-UTC offsets to UTC LocalDateTime`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002")
// Источник прислал то же мгновение, но со смещением +03:00. Голый toLocalDateTime() оставил бы
// локальную стенку (06:04:05); нормализация к UTC обязана дать 03:04:05.
server = serverWithRequestResponse(
mutableListOf(),
requestId,
beginDateTime = "2026-01-02T06:04:05+03:00",
endDateTime = "2026-01-03T07:05:06+03:00",
)
val request = requestServiceClient().getRequest(requestId)!!
assertEquals(LocalDateTime.of(2026, 1, 2, 3, 4, 5), request.intervalBegin)
assertEquals(LocalDateTime.of(2026, 1, 3, 4, 5, 6), request.intervalEnd)
}
private fun serverWithRequestResponse(
requestedUris: MutableList<URI>,
requestId: UUID,
beginDateTime: String = "2026-01-02T03:04:05Z",
endDateTime: String = "2026-01-03T04:05:06Z",
): HttpServer =
HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/v1/requests") { exchange ->
@@ -77,8 +97,8 @@ class RequestServiceClientTest {
"surveyType": "OPTICS",
"geometry": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
"importance": 7.5,
"beginDateTime": "2026-01-02T03:04:05Z",
"endDateTime": "2026-01-03T04:05:06Z",
"beginDateTime": "$beginDateTime",
"endDateTime": "$endDateTime",
"kpp": [1],
"highPriorityTransmit": false,
"optics": {