Формат времени в UTC
This commit is contained in:
+9
-7
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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()
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+28
-14
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+3
-2
@@ -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")
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+7
-5
@@ -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
|
||||
}
|
||||
|
||||
+6
-3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+10
-6
@@ -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(
|
||||
|
||||
+59
@@ -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()
|
||||
}
|
||||
+40
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user