pcp-tgu-service: миграция с WebFlux на servlet/MVC

WebFlux в tgu был фиктивным: блокирующая JPA, контроллеры оборачивали
блокирующие вызовы в Mono.fromCallable{}.subscribeOn(boundedElastic()),
WebClient .block()-ался сразу. Единственный WebFlux-only сервис флота.

- build: подключён pcp-types-lib (тянет starter-web → servlet), springdoc
  webflux-ui → webmvc-ui; starter-webflux оставлен только ради WebClient
- контроллеры возвращают значения напрямую (без Mono-обёрток)
- удалено локальное Jackson-2 зеркало PcpTimeModule.kt → модуль из либы
  в CamundaJacksonConfig; Jackson-3 зеркало остаётся (Boot 4 держит
  Jackson 3 на HTTP-границе и для MVC), WebFluxJacksonConfig → RestJacksonConfig
This commit is contained in:
Дмитрий Соловьев
2026-06-02 23:14:12 +03:00
parent cde4ffa9b6
commit e68c17e792
9 changed files with 78 additions and 182 deletions
+7 -1
View File
@@ -17,11 +17,17 @@ java {
dependencies {
// Общие типы/слой толерантности времени; тянет spring-boot-starter-web (servlet/MVC),
// из-за чего приложение запускается как сервлетное (MVC), а не WebFlux.
implementation(project(":libs:pcp-types-lib"))
// Spring Boot
// starter-webflux оставлен только ради WebClient (внешние HTTP-клиенты, см. integration/api);
// тип приложения — сервлетный (MVC), т.к. в classpath есть spring-webmvc из starter-web выше.
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-actuator")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.0")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
@@ -10,6 +10,7 @@ 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,8 +26,8 @@ class CamundaJacksonConfig {
.addModule(KotlinModule.Builder().build())
.addModule(JavaTimeModule())
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
// Покрывает Jackson-2 путь (Camunda/внутренний парсинг). REST-codec на Jackson 3
// (WebFlux auto-config) покрыт близнецом PcpTimeModuleV3 (см. WebFluxJacksonConfig).
// Модуль из 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 com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeParseException
/**
* Локальное зеркало слоя толерантности времени из libs/pcp-types-lib
* (`space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`).
*
* Канонический источник — там; здесь копия, потому что pcp-tgu-service работает на WebFlux,
* а pcp-types-lib тянет `spring-boot-starter-web` (MVC/Tomcat) и переключил бы тип приложения
* на сервлетный. Поведение обязано совпадать с библиотечным; правки синхронизировать.
*
* Любой потребитель с этим модулем читает время в обоих форматах на проводе (naive=UTC и `Z`/офсет),
* нормализуя к UTC. Трогает только десериализацию. См. docs/standards/DATETIME_UTC.md.
*/
class PcpTimeModule : SimpleModule(NAME) {
init {
addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer())
addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer())
addDeserializer(Instant::class.java, TolerantInstantDeserializer())
}
companion object {
const val NAME = "PcpTimeModule"
}
}
private class TolerantLocalDateTimeDeserializer : JsonDeserializer<LocalDateTime>() {
private val default = LocalDateTimeDeserializer.INSTANCE
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? {
if (p.currentToken() == JsonToken.VALUE_STRING) {
val text = p.text.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 TolerantOffsetDateTimeDeserializer : JsonDeserializer<OffsetDateTime>() {
@Suppress("UNCHECKED_CAST")
private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer<OffsetDateTime>
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? {
if (p.currentToken() == JsonToken.VALUE_STRING) {
val text = p.text.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 TolerantInstantDeserializer : JsonDeserializer<Instant>() {
@Suppress("UNCHECKED_CAST")
private val default = InstantDeserializer.INSTANT as JsonDeserializer<Instant>
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? {
if (p.currentToken() == JsonToken.VALUE_STRING) {
val text = p.text.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)
}
}
@@ -16,11 +16,12 @@ import java.time.format.DateTimeParseException
/**
* Слой толерантности времени для **Jackson 3** (`tools.jackson.*`).
*
* Близнец [PcpTimeModule] (Jackson 2): то же поведение — читать время в обоих форматах на проводе
* (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. Нужен отдельно, потому
* что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST-кодеков WebFlux, а он не видит
* Jackson-2 модули. Регистрируется как бин `tools.jackson.databind.JacksonModule`
* ([WebFluxJacksonConfig]) — `JacksonAutoConfiguration` собирает такие бины в общий `JsonMapper`.
* Близнец библиотечного `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.
@@ -6,16 +6,15 @@ import tools.jackson.databind.JacksonModule
/**
* Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3
* `JsonMapper`, который Boot 4 использует для REST-кодеков WebFlux.
* `JsonMapper`, который Boot 4 использует для REST HTTP message converters (MVC).
*
* `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста
* и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин
* этого типа. Закрывает tgu-follow-up из docs/standards/DATETIME_UTC.md: входящий REST-JSON теперь
* читается толерантно к обоим форматам времени (naive=UTC и `Z`), как и Jackson-2 путь
* ([CamundaJacksonConfig]).
* этого типа. Входящий REST-JSON читается толерантно к обоим форматам времени (naive=UTC и `Z`),
* как и Jackson-2 путь Camunda ([CamundaJacksonConfig]). См. docs/standards/DATETIME_UTC.md.
*/
@Configuration
class WebFluxJacksonConfig {
class RestJacksonConfig {
@Bean
fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3()
@@ -6,8 +6,6 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import space.nstart.pcp_tgu_service.dto.PlanResponse
import space.nstart.pcp_tgu_service.service.PlanQueryService
@@ -19,18 +17,14 @@ class PlanController(
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
@GetMapping
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono<List<PlanResponse>> =
Mono.fromCallable {
planQueryService.getAllPlans(historyDays)
}.subscribeOn(Schedulers.boundedElastic())
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): List<PlanResponse> =
planQueryService.getAllPlans(historyDays)
/** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */
@GetMapping("/{spacecraftId}")
fun getPlansBySpacecraftId(
@PathVariable spacecraftId: String,
@RequestParam(defaultValue = "7") historyDays: Long
): Mono<List<PlanResponse>> =
Mono.fromCallable {
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
}.subscribeOn(Schedulers.boundedElastic())
): List<PlanResponse> =
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
}
@@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
/**
* REST-контроллер для просмотра списка космических аппаратов.
@@ -30,10 +28,8 @@ class PlatformController(
@Operation(summary = "Получить список космических аппаратов")
fun getPlatforms(
@RequestParam(required = false) status: String?
): Mono<List<PlatformResponse>> =
Mono.fromCallable {
platformService.loadPlatforms(status).map { platform -> platform.toResponse() }
}.subscribeOn(Schedulers.boundedElastic())
): List<PlatformResponse> =
platformService.loadPlatforms(status).map { platform -> platform.toResponse() }
private fun PlatformCatalogItem.toResponse(): PlatformResponse =
PlatformResponse(
@@ -9,8 +9,6 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.dto.PlanResponse
@@ -49,31 +47,25 @@ class TestTguController(
fun refreshVisibility(
@PathVariable spacecraftId: String,
@RequestParam(required = false) noradId: Long?
): Mono<Void> =
Mono.fromRunnable<Void> {
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER")
}.subscribeOn(Schedulers.boundedElastic())
) {
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER")
}
/** Возвращает вычисленную on-demand проекцию будущей цепочки КА (диагностика). */
@GetMapping("/spacecraft/{spacecraftId}/projection")
fun getProjection(@PathVariable spacecraftId: String): Mono<List<PlanResponse>> =
Mono.fromCallable {
planProjectionService.project(spacecraftId).map(PlanResponse::projected)
}.subscribeOn(Schedulers.boundedElastic())
fun getProjection(@PathVariable spacecraftId: String): List<PlanResponse> =
planProjectionService.project(spacecraftId).map(PlanResponse::projected)
/** Принудительно выдаёт конкретный persisted plan через новую attempt. */
@PostMapping("/plans/{planId}/issue")
fun issuePlan(@PathVariable planId: UUID): Mono<ProcessStartResponse> =
Mono.fromCallable {
issueDuePlanWorker.issuePlanNow(planId)
}.subscribeOn(Schedulers.boundedElastic())
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
issueDuePlanWorker.issuePlanNow(planId)
/** Инжектит decision event через тот же use case, что Kafka consumer. */
@PostMapping("/plan-decisions")
fun handleDecision(@RequestBody message: PlanDecisionMessage): Mono<Void> =
Mono.fromRunnable<Void> {
handlePlanDecisionUseCase.handle(message)
}.subscribeOn(Schedulers.boundedElastic())
fun handleDecision(@RequestBody message: PlanDecisionMessage) {
handlePlanDecisionUseCase.handle(message)
}
/** Генерирует ACCEPTED/REJECTED decision event для active attempt указанного persisted plan. */
@PostMapping("/plans/{planId}/decision")
@@ -81,23 +73,20 @@ class TestTguController(
@PathVariable planId: UUID,
@RequestParam decision: PlanDecision,
@RequestParam(required = false) reason: String?
): Mono<PlanDecisionMessage> =
Mono.fromCallable {
testPlanDecisionService.generateDecision(planId, decision, reason)
}.subscribeOn(Schedulers.boundedElastic())
): PlanDecisionMessage =
testPlanDecisionService.generateDecision(planId, decision, reason)
/** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */
@PostMapping("/platforms/load-from-file")
fun loadPlatformsFromFile(@RequestParam("path") filePath: String): Mono<TestPlatformsLoadResponse> =
Mono.fromCallable {
val path = Path.of(filePath).toAbsolutePath().normalize()
val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path))
TestPlatformsLoadResponse(
filePath = path.toString(),
loadedCount = platforms.size,
allowedCount = platformService.loadAllowedPlatforms().size
)
}.subscribeOn(Schedulers.boundedElastic())
fun loadPlatformsFromFile(@RequestParam("path") filePath: String): TestPlatformsLoadResponse {
val path = Path.of(filePath).toAbsolutePath().normalize()
val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path))
return TestPlatformsLoadResponse(
filePath = path.toString(),
loadedCount = platforms.size,
allowedCount = platformService.loadAllowedPlatforms().size
)
}
/** Загружает raw payload окон видимости из баллистики для диагностики. */
@GetMapping("/external-points/{noradId}")
@@ -109,17 +98,13 @@ class TestTguController(
@RequestParam("time_stop")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
timeStop: LocalDateTime
): Mono<String> =
Mono.fromCallable {
externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop)
}.subscribeOn(Schedulers.boundedElastic())
): String =
externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop)
/** Возвращает persisted plan для проверки результата ручных операций. */
@GetMapping("/plans/{planId}")
fun getPlan(@PathVariable planId: UUID): Mono<PlanResponse> =
Mono.fromCallable {
PlanResponse.from(planQueryService.getByPlanId(planId))
}.subscribeOn(Schedulers.boundedElastic())
fun getPlan(@PathVariable planId: UUID): PlanResponse =
PlanResponse.from(planQueryService.getByPlanId(planId))
}
data class TestPlatformsLoadResponse(