Формат времени в UTC

This commit is contained in:
emelianov
2026-06-25 16:21:17 +03:00
parent d0ec0ef754
commit fe54c095ab
164 changed files with 1847 additions and 634 deletions
@@ -8,10 +8,12 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AngularMotionCalculationRequestDTO
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.time.format.DateTimeParseException
@RestController
@RequestMapping("/api/angular-motion")
@@ -43,12 +45,12 @@ class AngularMotionController(
return null
}
val decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8)
val normalized = if (decoded.length == 16) "$decoded:00" else decoded
return runCatching { LocalDateTime.parse(normalized) }
.getOrElse {
throw CustomValidationException(
"Некорректный формат времени '$decoded'. Ожидается yyyy-MM-dd'T'HH:mm или yyyy-MM-dd'T'HH:mm:ss",
)
}
return try {
UtcDateTimes.parse(decoded)
} catch (_: DateTimeParseException) {
throw CustomValidationException(
"Некорректный формат времени '$decoded'. Ожидается ISO-8601 UTC/offset, например 2026-06-17T06:28:27Z, или legacy UTC-naive yyyy-MM-dd'T'HH:mm:ss",
)
}
}
}
@@ -0,0 +1,12 @@
package space.nstart.pcp.pcp_request_service.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
class TimeConfiguration {
@Bean
fun clock(): Clock = Clock.systemUTC()
}
@@ -0,0 +1,40 @@
package space.nstart.pcp.pcp_request_service.configuration
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import tools.jackson.core.JsonGenerator
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.SerializationContext
import tools.jackson.databind.deser.std.StdDeserializer
import tools.jackson.databind.module.SimpleModule
import tools.jackson.databind.ser.std.StdSerializer
import java.time.LocalDateTime
@Configuration
class UtcTimeJsonConfiguration {
@Bean
fun ballisticsUtcLocalDateTimeCustomizer(): JsonMapperBuilderCustomizer = JsonMapperBuilderCustomizer { builder ->
builder.addModule(
SimpleModule("pcp-ballistics-utc-local-date-time")
.addSerializer(LocalDateTime::class.java, UtcLocalDateTimeSerializer())
.addDeserializer(LocalDateTime::class.java, UtcLocalDateTimeDeserializer())
)
}
}
private class UtcLocalDateTimeSerializer : StdSerializer<LocalDateTime>(LocalDateTime::class.java) {
override fun serialize(value: LocalDateTime, gen: JsonGenerator, ctxt: SerializationContext) {
gen.writeString(UtcDateTimes.format(value))
}
}
private class UtcLocalDateTimeDeserializer : StdDeserializer<LocalDateTime>(LocalDateTime::class.java) {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime {
val value = p.valueAsString ?: throw IllegalArgumentException("Expected ISO-8601 UTC date-time string")
return UtcDateTimes.parse(value)
}
}
@@ -15,9 +15,11 @@ import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
import space.nstart.pcp.pcp_request_service.service.SatelliteService
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import java.time.LocalDateTime
import java.time.format.DateTimeParseException
@RestController
@@ -61,11 +63,11 @@ class SatelliteController {
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime?,
@RequestParam("time_start", required = false) timeStart : String?,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime?
@RequestParam("time_stop", required = false) timeStrop : String?
) =
satelliteService.getOrbPoints(noradId, timeStart, timeStrop)
satelliteService.getOrbPoints(noradId, parseOptionalUtcDateTime("time_start", timeStart), parseOptionalUtcDateTime("time_stop", timeStrop))
@Operation(
summary = "Получить доступные интервалы прогноза по всем КА",
@@ -74,9 +76,9 @@ class SatelliteController {
@GetMapping("/orbit/availability")
fun getOrbitAvailability(
@Parameter(description = "Проверяемое время. Если null, возвращаются все КА, для которых есть точки ПДЦМ")
@RequestParam("time", required = false) time: LocalDateTime?
@RequestParam("time", required = false) time: String?
) =
satelliteService.getOrbitAvailability(time)
satelliteService.getOrbitAvailability(parseOptionalUtcDateTime("time", time))
@GetMapping("/orbit/availability/{id}")
@@ -96,11 +98,11 @@ class SatelliteController {
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime?,
@RequestParam("time_start", required = false) timeStart : String?,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime?
@RequestParam("time_stop", required = false) timeStrop : String?
) =
satelliteService.getAscNodes(noradId, timeStart, timeStrop)
satelliteService.getAscNodes(noradId, parseOptionalUtcDateTime("time_start", timeStart), parseOptionalUtcDateTime("time_stop", timeStrop))
@Operation(
@@ -112,11 +114,11 @@ class SatelliteController {
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime?,
@RequestParam("time_start", required = false) timeStart : String?,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime?
@RequestParam("time_stop", required = false) timeStrop : String?
) =
satelliteService.getFL(noradId, timeStart, timeStrop, 60.0)
satelliteService.getFL(noradId, parseOptionalUtcDateTime("time_start", timeStart), parseOptionalUtcDateTime("time_stop", timeStrop), 60.0)
@@ -130,11 +132,11 @@ class SatelliteController {
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime,
@RequestParam("time_start") timeStart : String,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime
@RequestParam("time_stop") timeStrop : String
) =
satelliteService.getRVA(noradId, timeStart, timeStrop)
satelliteService.getRVA(noradId, parseRequiredUtcDateTime("time_start", timeStart), parseRequiredUtcDateTime("time_stop", timeStrop))
@PostMapping("/{satellite_id}/clear")
@@ -143,4 +145,16 @@ class SatelliteController {
) =
satelliteService.clear(satelliteId)
private fun parseOptionalUtcDateTime(name: String, value: String?): LocalDateTime? =
value?.let { parseRequiredUtcDateTime(name, it) }
private fun parseRequiredUtcDateTime(name: String, value: String): LocalDateTime =
try {
UtcDateTimes.parse(value)
} catch (_: DateTimeParseException) {
throw CustomValidationException(
"Некорректный формат параметра '$name': '$value'. Ожидается ISO-8601 UTC/offset, например 2026-06-17T06:28:27Z, или legacy UTC-naive yyyy-MM-dd'T'HH:mm:ss",
)
}
}
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_request_service.entity
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import ballistics.types.KeplerParams
import ballistics.types.RevolutionParameter
import ballistics.utils.fromDateTime
@@ -26,7 +27,7 @@ class AscNodeEntity(
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(),
val time : LocalDateTime = UtcDateTimes.now(),
@Column(nullable = false)
val revolution : Long = 0,
val longitude : Double = 0.0,
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_request_service.entity
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import ballistics.utils.toDateTime
import jakarta.persistence.Column
import jakarta.persistence.Entity
@@ -26,13 +27,13 @@ class EarthCellViewsEntity(
@Column(name = "rev_begin", nullable = false)
val revolutionBegin : Long = 0,
@Column(nullable = false)
val timeBegin : LocalDateTime = LocalDateTime.now(),
val timeBegin : LocalDateTime = UtcDateTimes.now(),
@Column(nullable = false)
val revSignBegin : Int = 0,
@Column(name = "rev_end", nullable = false)
val revolutionEnd: Long = 0,
@Column(nullable = false)
val timeEnd : LocalDateTime = LocalDateTime.now(),
val timeEnd : LocalDateTime = UtcDateTimes.now(),
@Column(nullable = false)
val revSignEnd : Int = 0,
@Column(name = "roll_min")
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_request_service.entity
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import ballistics.types.FleghtLineSector
import ballistics.utils.toDateTime
import jakarta.persistence.Column
@@ -22,9 +23,9 @@ class EarthCoverageEntity(
@Column(nullable = false)
val cellNumber : Long = 0,
@Column(nullable = false)
val tStart : LocalDateTime = LocalDateTime.now(),
val tStart : LocalDateTime = UtcDateTimes.now(),
@Column(nullable = false)
val tStop : LocalDateTime = LocalDateTime.now(),
val tStop : LocalDateTime = UtcDateTimes.now(),
) {
constructor(fl : FleghtLineSector, sector : Int, sat : Long) : this(
null,
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_request_service.entity
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import ballistics.types.FlightLine
import ballistics.utils.toDateTime
import jakarta.persistence.*
@@ -19,7 +20,7 @@ class FlightLineEntity(
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(),
val time : LocalDateTime = UtcDateTimes.now(),
@Column(nullable = false)
val revolution : Long = 0,
@Column(nullable = false)
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_request_service.entity
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import ballistics.types.OrbitalPoint
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
@@ -18,7 +19,7 @@ class PDCMEntity (
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(),
val time : LocalDateTime = UtcDateTimes.now(),
@Column(nullable = false)
val revolution : Long = 0,
val x: Double = 0.0,
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_request_service.entity
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
@@ -27,7 +28,7 @@ class SatelliteInitialConditionEntity(
@Column(name = "movement_model", nullable = false)
val movementModel: MovementModel = MovementModel.FOTO,
@Column(name = "orb_time", nullable = false)
val orbTime: LocalDateTime = LocalDateTime.now(),
val orbTime: LocalDateTime = UtcDateTimes.now(),
@Column(name = "orb_revolution", nullable = false)
val orbRevolution: Long = 0,
@Column(nullable = false)
@@ -47,13 +48,13 @@ class SatelliteInitialConditionEntity(
@Column(nullable = false)
val f81: Double = 147.8,
@Column(name = "received_at", nullable = false)
val receivedAt: LocalDateTime = LocalDateTime.now(),
val receivedAt: LocalDateTime = UtcDateTimes.now(),
@Column(name = "calculated_at")
var calculatedAt: LocalDateTime? = null,
@Column(name = "last_calculated", nullable = false)
var lastCalculated: Boolean = false
) {
constructor(request: SatelliteICDTO) : this(
constructor(request: SatelliteICDTO, receivedAt: LocalDateTime = UtcDateTimes.now()) : this(
satelliteId = request.satelliteId,
movementModel = request.movementModel,
orbTime = request.ic.orbPoint.time,
@@ -65,10 +66,11 @@ class SatelliteInitialConditionEntity(
vy = request.ic.orbPoint.vy,
vz = request.ic.orbPoint.vz,
sBall = request.ic.sBall,
f81 = request.ic.f81
f81 = request.ic.f81,
receivedAt = receivedAt
)
fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now()) {
fun markLastCalculated(calculatedAt: LocalDateTime = UtcDateTimes.now()) {
this.calculatedAt = calculatedAt
this.lastCalculated = true
}
@@ -1,6 +1,8 @@
package space.nstart.pcp.pcp_request_service.service
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import java.time.Clock
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_request_service.entity.SatelliteInitialConditionEntity
import space.nstart.pcp.pcp_request_service.repository.SatelliteInitialConditionRepository
@@ -8,17 +10,18 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
@Service
class SatelliteInitialConditionService(
private val satelliteInitialConditionRepository: SatelliteInitialConditionRepository
private val satelliteInitialConditionRepository: SatelliteInitialConditionRepository,
private val clock: Clock
) {
@Transactional
fun saveReceived(request: SatelliteICDTO): SatelliteInitialConditionEntity =
satelliteInitialConditionRepository.save(SatelliteInitialConditionEntity(request))
satelliteInitialConditionRepository.save(SatelliteInitialConditionEntity(request, UtcDateTimes.now(clock)))
@Transactional
fun markLastCalculated(initialCondition: SatelliteInitialConditionEntity) {
satelliteInitialConditionRepository.clearLastCalculated(initialCondition.satelliteId)
initialCondition.markLastCalculated()
initialCondition.markLastCalculated(UtcDateTimes.now(clock))
satelliteInitialConditionRepository.save(initialCondition)
}
@@ -44,6 +44,7 @@ import space.nstart.pcp.pcp_request_service.repository.EarthCoverageRepository
import space.nstart.pcp.pcp_request_service.repository.FlightLineRepository
import space.nstart.pcp.pcp_request_service.repository.PDCMRepository
import space.nstart.pcp.pcp_request_service.utils.ContourClipService
import space.nstart.pcp.pcp_request_service.utils.UtcDateTimes
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
@@ -62,6 +63,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityD
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import tools.jackson.databind.ObjectMapper
import java.time.Clock
import java.time.LocalDateTime
import kotlin.collections.forEach
import kotlin.math.PI
@@ -105,6 +107,8 @@ class SatelliteService {
@Autowired
private lateinit var stationCatalogClient: StationCatalogClient
@Autowired
private lateinit var clock: Clock
val astro = AstronomerJ2000(EarthType.PZ90d02)
@@ -761,8 +765,8 @@ class SatelliteService {
fun getOrbPoints(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) =
pdcmRepository.findBySatelliteIdAndTimeBetween(
id,
tn?: LocalDateTime.now(),
tk?: LocalDateTime.now().plusDays(1))
tn?: UtcDateTimes.now(clock),
tk?: UtcDateTimes.now(clock).plusDays(1))
.map { it.toDTO()
}
@@ -780,8 +784,8 @@ class SatelliteService {
fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) =
flightLineRepository.findBySatelliteIdAndTimeBetween(id,
tn?: LocalDateTime.now(),
tk?: LocalDateTime.now().plusDays(1)
tn?: UtcDateTimes.now(clock),
tk?: UtcDateTimes.now(clock).plusDays(1)
)
.map { it.toDTO() }
@@ -902,8 +906,8 @@ class SatelliteService {
return ascNodeRepository.findBySatelliteIdAndTimeBetween(
id,
tn ?: LocalDateTime.now(),
tk ?: LocalDateTime.now().plusDays(1)
tn ?: UtcDateTimes.now(clock),
tk ?: UtcDateTimes.now(clock).plusDays(1)
)
.map {
val keps = calculateKeplerParams(
@@ -0,0 +1,59 @@
package space.nstart.pcp.pcp_request_service.utils
import java.time.Clock
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* UTC boundary helpers for pcp-ballistics-service.
*
* Domain and database still use LocalDateTime, but every value is treated as an
* absolute UTC instant without a stored zone (UTC-naive). Public API values may
* be sent either as UTC/offset ISO strings or as legacy naive ISO strings;
* offset values are normalized to UTC LocalDateTime.
*/
object UtcDateTimes {
val UTC: ZoneOffset = ZoneOffset.UTC
fun now(clock: Clock = Clock.systemUTC()): LocalDateTime =
LocalDateTime.ofInstant(clock.instant(), UTC)
fun parse(value: String): LocalDateTime {
val normalized = normalize(value)
parseOffset(normalized)?.let { return it }
parseInstant(normalized)?.let { return it }
parseLocal(normalized)?.let { return it }
throw DateTimeParseException(
"Expected ISO-8601 UTC/offset date-time or legacy UTC-naive date-time",
value,
0
)
}
fun format(value: LocalDateTime): String = value.toInstant(UTC).toString()
private fun normalize(value: String): String {
val trimmed = value.trim().replace(Regex(" (\\d{2}:\\d{2})$"), "+\$1")
return if (trimmed.length == 16) "$trimmed:00" else trimmed
}
private fun parseOffset(value: String): LocalDateTime? = runCatching {
OffsetDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME)
.withOffsetSameInstant(UTC)
.toLocalDateTime()
}.getOrNull()
private fun parseInstant(value: String): LocalDateTime? = runCatching {
LocalDateTime.ofInstant(Instant.parse(value), UTC)
}.getOrNull()
private fun parseLocal(value: String): LocalDateTime? = runCatching {
LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
}.getOrNull()
}
@@ -0,0 +1,40 @@
package space.nstart.pcp.pcp_request_service.utils
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class UtcDateTimesTest {
@Test
fun `parses utc instant with z suffix`() {
assertEquals(
LocalDateTime.of(2026, 6, 17, 6, 28, 27, 334_000_000),
UtcDateTimes.parse("2026-06-17T06:28:27.334Z")
)
}
@Test
fun `normalizes offset date time to utc naive local date time`() {
assertEquals(
LocalDateTime.of(2026, 6, 17, 6, 28, 27),
UtcDateTimes.parse("2026-06-17T09:28:27+03:00")
)
}
@Test
fun `keeps legacy naive date time as utc naive`() {
assertEquals(
LocalDateTime.of(2026, 6, 17, 6, 28, 0),
UtcDateTimes.parse("2026-06-17T06:28")
)
}
@Test
fun `formats utc naive date time with z suffix`() {
assertEquals(
"2026-06-17T06:28:27.334Z",
UtcDateTimes.format(LocalDateTime.of(2026, 6, 17, 6, 28, 27, 334_000_000))
)
}
}