This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,15 @@
package org.nstart.dep265.requestservice
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
class PcpRequestServiceApplication
fun main(args: Array<String>) {
runApplication<PcpRequestServiceApplication>(*args)
}
@@ -0,0 +1,22 @@
package org.nstart.dep265.requestservice.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JacksonConfig {
/**
* The service code still depends on Jackson 2 APIs, while the shared Boot 4 stack
* auto-configures Jackson 3 for MVC. Keep a dedicated Jackson 2 mapper for internal
* parsing until the module is migrated.
*/
@Bean
fun objectMapper(): ObjectMapper {
return jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules()
}
}
@@ -0,0 +1,34 @@
package org.nstart.dep265.requestservice.config
import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.serialization.StringSerializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
@Configuration
class KafkaProducerConfig {
@Bean
@ConditionalOnMissingBean
fun requestProducerFactory(
@Value("\${spring.kafka.bootstrap-servers:192.168.100.160:19092}") bootstrapServers: String,
): ProducerFactory<String, String> {
return DefaultKafkaProducerFactory(
mapOf(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
),
)
}
@Bean
@ConditionalOnMissingBean
fun requestKafkaTemplate(producerFactory: ProducerFactory<String, String>): KafkaTemplate<String, String> {
return KafkaTemplate(producerFactory)
}
}
@@ -0,0 +1,20 @@
package org.nstart.dep265.requestservice.config
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class OpenApiConfig {
@Bean
fun requestServiceOpenApi(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("PCP Request Service API")
.description("REST API сервиса заявок PCP.")
.version("v1"),
)
}
}
@@ -0,0 +1,10 @@
package org.nstart.dep265.requestservice.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "pcp.outbox")
data class OutboxProperties(
val requestCompletedTopic: String = "pcp.request.completed.v1",
val publishBatchSize: Int = 50,
val publishFixedDelayMs: Long = 5_000,
)
@@ -0,0 +1,25 @@
package org.nstart.dep265.requestservice.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionOperations
import org.springframework.transaction.support.TransactionTemplate
@Configuration
class OutboxTransactionConfig {
@Bean("routeMatchingTransactionOperations")
fun routeMatchingTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations {
return TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRED
}
}
@Bean("outboxTransactionOperations")
fun outboxTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations {
return TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
}
}
@@ -0,0 +1,33 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.ErrorResponseDto
import org.nstart.dep265.requestservice.service.InvalidCellsQueryException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
@RestControllerAdvice(assignableTypes = [CellsController::class])
class CellsApiExceptionHandler {
@ExceptionHandler(InvalidCellsQueryException::class)
fun handleInvalidCellsQuery(exception: InvalidCellsQueryException): ResponseEntity<ErrorResponseDto> {
return validationError(exception.message ?: "Некорректные query parameters")
}
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity<ErrorResponseDto> {
return validationError("Некорректное значение query parameter: ${exception.name}")
}
private fun validationError(message: String): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = message,
),
)
}
}
@@ -0,0 +1,47 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.CellsListResponseDto
import org.nstart.dep265.requestservice.dto.CellsWithRequestsResponseDto
import org.nstart.dep265.requestservice.service.CellsQueryService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v1/cells")
class CellsController(
private val cellsQueryService: CellsQueryService,
) {
@GetMapping
fun listCells(
@RequestParam(required = false) minImportance: Double?,
@RequestParam(required = false) countLat: Int?,
@RequestParam(required = false) countLong: Int?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@RequestParam(required = false) sort: String?,
): CellsListResponseDto {
return cellsQueryService.listCells(
minImportance = minImportance,
countLat = countLat,
countLong = countLong,
page = page,
size = size,
sort = sort,
)
}
@GetMapping("/with-requests")
fun listCellsWithRequests(
@RequestParam(required = false) minImportance: Double?,
@RequestParam(required = false) countLat: Int?,
@RequestParam(required = false) countLong: Int?,
): CellsWithRequestsResponseDto {
return cellsQueryService.listCellsWithRequests(
minImportance = minImportance,
countLat = countLat,
countLong = countLong,
)
}
}
@@ -0,0 +1,63 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.ErrorDetailDto
import org.nstart.dep265.requestservice.dto.ErrorResponseDto
import org.nstart.dep265.requestservice.service.GridRebuildAlreadyRunningException
import org.nstart.dep265.requestservice.service.InvalidGridSettingsException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice(assignableTypes = [GridManagementController::class])
class GridManagementApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationError(exception: MethodArgumentNotValidException): ResponseEntity<ErrorResponseDto> {
val details = exception.bindingResult.fieldErrors.map { error ->
ErrorDetailDto(
field = error.field,
message = error.defaultMessage ?: "Некорректное значение",
)
}
return validationError("Ошибка валидации запроса", details)
}
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleNotReadable(exception: HttpMessageNotReadableException): ResponseEntity<ErrorResponseDto> {
return validationError(exception.mostSpecificCause.message ?: "Некорректный JSON-запрос")
}
@ExceptionHandler(InvalidGridSettingsException::class)
fun handleInvalidGridSettings(exception: InvalidGridSettingsException): ResponseEntity<ErrorResponseDto> {
return validationError(exception.message ?: "Некорректные настройки сетки")
}
@ExceptionHandler(GridRebuildAlreadyRunningException::class)
fun handleGridRebuildAlreadyRunning(exception: GridRebuildAlreadyRunningException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(
ErrorResponseDto(
code = "GRID_REBUILD_ALREADY_RUNNING",
message = exception.message ?: "Grid rebuild is already running",
),
)
}
private fun validationError(
message: String,
details: List<ErrorDetailDto>? = null,
): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = message,
details = details,
),
)
}
}
@@ -0,0 +1,38 @@
package org.nstart.dep265.requestservice.controller
import jakarta.validation.Valid
import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto
import org.nstart.dep265.requestservice.dto.GridSettingsResponseDto
import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto
import org.nstart.dep265.requestservice.service.GridRebuildService
import org.nstart.dep265.requestservice.service.GridSettingsService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v1/grid")
class GridManagementController(
private val gridSettingsService: GridSettingsService,
private val gridRebuildService: GridRebuildService,
) {
@GetMapping("/settings")
fun getGridSettings(): GridSettingsResponseDto {
return gridSettingsService.getSettings()
}
@PutMapping("/settings")
fun updateGridSettings(
@Valid @RequestBody request: UpdateGridSettingsRequestDto,
): GridSettingsResponseDto {
return gridSettingsService.updateSettings(request)
}
@PostMapping("/rebuild")
fun rebuildGrid(): GridRebuildResponseDto {
return gridRebuildService.rebuildGrid()
}
}
@@ -0,0 +1,70 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.ErrorDetailDto
import org.nstart.dep265.requestservice.dto.ErrorResponseDto
import org.nstart.dep265.requestservice.service.InvalidRequestListSortException
import org.nstart.dep265.requestservice.service.RequestAlreadyExistsException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice(assignableTypes = [RequestController::class])
class RequestApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationError(exception: MethodArgumentNotValidException): ResponseEntity<ErrorResponseDto> {
val details = exception.bindingResult.fieldErrors.map { error ->
ErrorDetailDto(
field = error.field,
message = error.defaultMessage ?: "Некорректное значение",
)
}
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = "Ошибка валидации запроса",
details = details,
),
)
}
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleNotReadable(exception: HttpMessageNotReadableException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = exception.mostSpecificCause.message ?: "Некорректный JSON-запрос",
),
)
}
@ExceptionHandler(RequestAlreadyExistsException::class)
fun handleRequestAlreadyExists(exception: RequestAlreadyExistsException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(
ErrorResponseDto(
code = "REQUEST_ALREADY_EXISTS",
message = "Заявка с таким id уже существует",
),
)
}
@ExceptionHandler(InvalidRequestListSortException::class)
fun handleInvalidRequestListSort(exception: InvalidRequestListSortException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = exception.message ?: "Некорректный параметр sort",
),
)
}
}
@@ -0,0 +1,89 @@
package org.nstart.dep265.requestservice.controller
import jakarta.validation.Valid
import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto
import org.nstart.dep265.requestservice.dto.CreateRequestResponseDto
import org.nstart.dep265.requestservice.dto.DeleteRequestResponseDto
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.RequestListResponseDto
import org.nstart.dep265.requestservice.dto.RequestResponseDto
import org.nstart.dep265.requestservice.dto.RequestStatusDto
import org.nstart.dep265.requestservice.dto.RequestWithCellsResponseDto
import org.nstart.dep265.requestservice.dto.SurveyTypeDto
import org.nstart.dep265.requestservice.service.RequestService
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
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 java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/v1/requests")
class RequestController(
private val requestService: RequestService,
) {
@PostMapping
fun createRequest(@Valid @RequestBody request: CreateRequestRequestDto): ResponseEntity<CreateRequestResponseDto> {
return ResponseEntity.status(201).body(requestService.createRequest(request))
}
@GetMapping
fun listRequests(
@RequestParam(required = false) status: RequestStatusDto?,
@RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@RequestParam(defaultValue = "createdAt,desc") sort: String,
): RequestListResponseDto {
val query = ListRequestsQueryDto(
status = status,
surveyType = surveyType,
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
beginFrom = beginFrom,
beginTo = beginTo,
endFrom = endFrom,
endTo = endTo,
includeDeleted = includeDeleted,
page = page,
size = size,
sort = sort,
)
return requestService.listRequests(query)
}
@GetMapping("/{id}")
fun getRequestById(@PathVariable id: UUID): ResponseEntity<RequestResponseDto> {
return requestService.getRequestById(id)
?.let { request -> ResponseEntity.ok(request) }
?: ResponseEntity.notFound().build()
}
@GetMapping("/{requestId}/with-cells")
fun getRequestWithCells(@PathVariable requestId: UUID): ResponseEntity<RequestWithCellsResponseDto> {
return requestService.getRequestWithCells(requestId)
?.let { response -> ResponseEntity.ok(response) }
?: ResponseEntity.notFound().build()
}
@DeleteMapping("/{id}")
fun deleteRequest(@PathVariable id: UUID): ResponseEntity<DeleteRequestResponseDto> {
return requestService.deleteRequest(id)
?.let { response -> ResponseEntity.ok(response) }
?: ResponseEntity.notFound().build()
}
}
@@ -0,0 +1,91 @@
package org.nstart.dep265.requestservice.dto
import java.util.UUID
/**
* Краткая карточка ячейки сетки для GET /v1/cells.
*/
data class CellSummaryResponseDto(
/** Номер ячейки сетки. */
val cellNum: Long,
/** Широта центра ячейки. */
val latitude: Double,
/** Долгота центра ячейки. */
val longitude: Double,
/** Расчетная важность ячейки. */
val importance: Double,
/** Контур ячейки в WKT. */
val contour: String,
)
/**
* Постраничный список ячеек сетки.
*/
data class CellsListResponseDto(
/** Элементы текущей страницы. */
val items: List<CellSummaryResponseDto>,
/** Номер страницы, начиная с 0. */
val page: Int,
/** Размер страницы. */
val size: Int,
/** Общее количество элементов. */
val totalItems: Long,
/** Общее количество страниц. */
val totalPages: Int,
)
/**
* Фрагмент заявки внутри ячейки сетки для GET /v1/cells/with-requests.
*/
data class CellRequestFragmentResponseDto(
/** Идентификатор заявки. */
val requestId: UUID,
/** Контур фрагмента заявки внутри ячейки в WKT. */
val contour: String,
/** Процент покрытия ячейки фрагментом заявки. */
val coveragePercent: Double,
/** Snapshot важности заявки из request_cells. */
val importance: Double,
)
/**
* Ячейка сетки с фрагментами заявок для GET /v1/cells/with-requests.
*/
data class CellWithRequestsResponseDto(
/** Номер ячейки сетки. */
val cellNum: Long,
/** Широта центра ячейки. */
val latitude: Double,
/** Долгота центра ячейки. */
val longitude: Double,
/** Расчетная важность ячейки. */
val importance: Double,
/** Контур ячейки в WKT. */
val contour: String,
/** Фрагменты заявок, пересекающие ячейку. */
val requests: List<CellRequestFragmentResponseDto>,
)
/**
* Список ячеек сетки с фрагментами заявок.
*/
data class CellsWithRequestsResponseDto(
/** Ячейки сетки с привязанными request_cells fragments. */
val items: List<CellWithRequestsResponseDto>,
)
@@ -0,0 +1,42 @@
package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.annotation.JsonFormat
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.NotNull
import java.time.OffsetDateTime
/**
* Текущие настройки сетки.
*/
data class GridSettingsResponseDto(
/** Идентификатор набора настроек сетки. */
val settingsId: Long,
/** Шаг построения сетки в градусах. */
val calculationStep: Double,
)
/**
* Обновляемые настройки сетки. Обновление не запускает rebuild автоматически.
*/
data class UpdateGridSettingsRequestDto(
/** Новый шаг построения сетки в градусах. */
@field:NotNull
@field:DecimalMin(value = "0.0", inclusive = false)
val calculationStep: Double?,
) : StrictRequestApiDto()
/**
* Результат явного перестроения сетки и request_cells projection.
*/
data class GridRebuildResponseDto(
/** Количество ячеек earth_cells после перестроения. */
val cellsCount: Long,
/** Количество активных заявок, для которых пересобраны request_cells. */
val rebuiltRequestProjectionsCount: Long,
/** Дата-время завершения перестроения в UTC. */
@field:JsonFormat(shape = JsonFormat.Shape.STRING)
val rebuiltAt: OffsetDateTime,
)
@@ -0,0 +1,467 @@
package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.annotation.JsonAnySetter
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.validation.Valid
import jakarta.validation.constraints.AssertTrue
import jakarta.validation.constraints.DecimalMax
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.time.OffsetDateTime
import java.util.UUID
/**
* Базовая защита OpenAPI DTO от полей вне schema.additionalProperties=false.
*/
abstract class StrictRequestApiDto {
@JsonAnySetter
fun rejectAdditionalProperty(name: String, value: Any?) {
throw IllegalArgumentException("Unknown request API property: $name")
}
}
/**
* Тело запроса на создание заявки по OpenAPI.
*/
data class CreateRequestRequestDto(
/** Внешний идентификатор заявки. */
@field:NotNull
val id: UUID,
/** Наименование задания. */
@field:NotBlank
@field:Size(max = 255)
val name: String,
/** Геометрия заявки в WKT. */
@field:NotBlank
val geometry: String,
/** Важность заявки для расчета важности ячеек сетки. */
@field:NotNull
@field:DecimalMin("0")
val importance: Double?,
/** Начало временного окна выполнения заявки. */
@field:NotNull
val beginDateTime: OffsetDateTime,
/** Окончание временного окна выполнения заявки. */
@field:NotNull
val endDateTime: OffsetDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
/** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean = false,
/** Параметры оптической съемки. */
@field:Valid
val optics: OpticsParamsDto? = null,
/** Параметры радиолокационной съемки РСА. */
@field:Valid
val rsa: RsaParamsDto? = null,
) : StrictRequestApiDto() {
/** В create-запросе должен быть указан хотя бы один payload-блок съемки. */
@get:AssertTrue(message = "Должен быть указан хотя бы один блок съемки: optics или rsa")
@get:JsonIgnore
val hasSurveyPayload: Boolean
get() = optics != null || rsa != null
}
/**
* Ответ на создание заявки.
*/
data class CreateRequestResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Наименование задания. */
val name: String,
/** Статус заявки после приема. */
val status: RequestStatusDto,
/** Вычисленный тип заявки по наличию optics/rsa. */
val surveyType: SurveyTypeDto,
/** Важность заявки для расчета важности ячеек сетки. */
val importance: Double,
/** Дополнительное сообщение для клиента. */
val message: String? = null,
)
/**
* Полная read-модель заявки.
*/
data class RequestResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Наименование задания. */
val name: String,
/** Текущий статус заявки. */
val status: RequestStatusDto,
/** Вычисленный тип заявки. */
val surveyType: SurveyTypeDto,
/** Полная геометрия заявки в WKT. */
val geometry: String,
/** Важность заявки для расчета важности ячеек сетки. */
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
/** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean,
/** Параметры оптической съемки, если заявка содержит optics. */
val optics: OpticsParamsDto? = null,
/** Параметры РСА-съемки, если заявка содержит rsa. */
val rsa: RsaParamsDto? = null,
/** Текущее состояние покрытия заявки. */
val coverage: CoverageStateDto,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
)
/**
* Краткая карточка заявки для list endpoint.
*/
data class RequestSummaryResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Наименование задания. */
val name: String,
/** Текущий статус заявки. */
val status: RequestStatusDto,
/** Вычисленный тип заявки. */
val surveyType: SurveyTypeDto,
/** Важность заявки для расчета важности ячеек сетки. */
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
/** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean,
/** Текущий процент покрытия заявки. */
val coveragePercent: Double,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
)
/**
* Постраничный ответ list endpoint.
*/
data class RequestListResponseDto(
/** Элементы текущей страницы. */
val items: List<RequestSummaryResponseDto>,
/** Номер страницы, начиная с 0. */
val page: Int,
/** Размер страницы. */
val size: Int,
/** Общее количество элементов. */
val totalItems: Long,
/** Общее количество страниц. */
val totalPages: Int,
)
/**
* Ячейка, связанная с заявкой через request_cells projection.
*/
data class RequestCellResponseDto(
/** Номер ячейки сетки из earth_cells. */
val cellNum: Long,
/** Процент покрытия ячейки фрагментом заявки из request_cells. */
val coveragePercent: Double,
/** Snapshot важности заявки из request_cells. */
val importance: Double,
/** Контур фрагмента заявки внутри ячейки в WKT из request_cells. */
val contour: String,
)
/**
* Заявка с проекциями на ячейки сетки.
*/
data class RequestWithCellsResponseDto(
/** Полная read-модель заявки нового /v1 API. */
val request: RequestResponseDto,
/** Проекции заявки на ячейки сетки из request_cells. */
val cells: List<RequestCellResponseDto>,
)
/**
* Ответ на soft delete заявки.
*/
data class DeleteRequestResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Итоговый статус после удаления. */
val status: DeleteRequestStatusDto,
/** Время soft delete. */
val deletedAt: OffsetDateTime,
)
/**
* Состояние покрытия заявки.
*/
data class CoverageStateDto(
/** Требуемый процент покрытия, если он задан доменной моделью. */
val requiredPercent: Double? = null,
/** Текущий процент покрытия. */
val currentPercent: Double,
)
/**
* Параметры оптической съемки.
*/
data class OpticsParamsDto(
/** Результат оптической съемки. */
@field:NotNull
val resultType: OpticsResultTypeDto,
/** Требуемое разрешение съемки, метры. */
@field:DecimalMin("0.1")
val resolution: Double,
/** Минимальная высота Солнца, градусы. */
@field:DecimalMin("0")
@field:DecimalMax("90")
val sunAngleMin: Double = 10.0,
/** Максимальная высота Солнца, градусы. */
@field:DecimalMin("0")
@field:DecimalMax("90")
val sunAngleMax: Double = 90.0,
/** Максимально допустимая облачность, проценты. */
@field:DecimalMin("0")
@field:DecimalMax("100")
val clouds: Double = 100.0,
) : StrictRequestApiDto()
/**
* Параметры радиолокационной съемки РСА.
*/
data class RsaParamsDto(
/** Тип аппаратуры РСА. */
@field:NotNull
val resultType: RsaResultTypeDto,
/** Признак интерферометрии. */
val interferometry: Boolean = false,
/** Тип поляризации. */
@field:NotNull
val polarisation: PolarisationDto,
/** Требуемое разрешение съемки, метры. */
@field:DecimalMin("0.1")
val resolution: Double,
) : StrictRequestApiDto()
/**
* Ошибка API.
*/
data class ErrorResponseDto(
/** Машиночитаемый код ошибки. */
val code: String,
/** Человекочитаемое описание ошибки. */
val message: String,
/** Детали ошибки по полям. */
val details: List<ErrorDetailDto>? = null,
)
/**
* Деталь ошибки валидации.
*/
data class ErrorDetailDto(
/** Поле, к которому относится ошибка. */
val field: String,
/** Описание ошибки поля. */
val message: String,
)
/**
* Query parameters list endpoint.
*/
data class ListRequestsQueryDto(
/** Фильтр по статусу заявки. */
val status: RequestStatusDto? = null,
/** Фильтр по вычисленному типу заявки. */
val surveyType: SurveyTypeDto? = null,
/** Фильтр по номеру КПП. */
val kpp: Int? = null,
/** Фильтр по признаку высокоприоритетной передачи. */
val highPriorityTransmit: Boolean? = null,
/** Нижняя граница beginDateTime, включительно. */
val beginFrom: OffsetDateTime? = null,
/** Верхняя граница beginDateTime, включительно. */
val beginTo: OffsetDateTime? = null,
/** Нижняя граница endDateTime, включительно. */
val endFrom: OffsetDateTime? = null,
/** Верхняя граница endDateTime, включительно. */
val endTo: OffsetDateTime? = null,
/** Включать soft-deleted заявки в результат. */
val includeDeleted: Boolean = false,
/** Номер страницы, начиная с 0. */
@field:Min(0)
val page: Int = 0,
/** Размер страницы. */
@field:Min(1)
@field:Max(500)
val size: Int = 50,
/** Сортировка в формате field,direction. */
val sort: String = "createdAt,desc",
)
/**
* Доменный статус заявки в OpenAPI.
*/
enum class RequestStatusDto {
/** Заявка прошла валидацию и принята сервисом. */
ACCEPTED,
/** Заявка актуальна для обработки. */
ACTIVE,
/** Заявка выполнена. */
COMPLETED,
/** Временное окно заявки истекло. */
EXPIRED,
/** Заявка мягко удалена. */
DELETED,
}
/**
* Статус ответа delete endpoint.
*/
enum class DeleteRequestStatusDto {
/** Заявка удалена или уже была удалена ранее. */
DELETED,
}
/**
* Вычисленный тип заявки по наличию optics/rsa.
*/
enum class SurveyTypeDto {
/** В заявке указан только optics. */
OPTICS,
/** В заявке указан только rsa. */
RSA,
/** В заявке указаны optics и rsa. */
COMBINED,
}
/**
* Результат оптической съемки.
*/
enum class OpticsResultTypeDto {
/** Панхроматическая съемка. */
PANCHROMATIC,
/** Мультиспектральная съемка. */
MULTISPECTRAL,
/** Паншарпенинг. */
PANSHARPENING,
}
/**
* Тип аппаратуры РСА.
*/
enum class RsaResultTypeDto {
/** Spotlight режим. */
SPOTLIGHT,
/** Stripmap режим. */
STRIPMAP,
/** ScanSAR режим. */
SCANSAR,
}
/**
* Тип поляризации РСА.
*/
enum class PolarisationDto {
/** Горизонтальная передача и горизонтальный прием. */
HH,
/** Вертикальная передача и вертикальный прием. */
VV,
}
@@ -0,0 +1,46 @@
package org.nstart.dep265.requestservice.dto
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import java.time.LocalDateTime
import java.util.UUID
/**
* Абсолютный диапазон крена маршрута в градусах.
*/
data class RouteAngleRangeDto(
/** Минимальное абсолютное значение крена. */
val min: Double,
/** Максимальное абсолютное значение крена. */
val max: Double,
) {
init {
require(min <= max) { "Минимальный крен маршрута не может быть больше максимального" }
}
fun average(): Double = (min + max) / 2.0
}
/**
* Внутренняя DTO маршрута, используемая request-service для matching заявок с маршрутами.
*/
data class RouteDto(
/** Идентификатор маршрута. */
val routeId: UUID,
/** Начало интервала выполнения маршрута. */
@field:NotNull(message = "Время начала маршрута обязательно")
val intervalBegin: LocalDateTime,
/** Конец интервала выполнения маршрута. */
@field:NotNull(message = "Время окончания маршрута обязательно")
val intervalEnd: LocalDateTime,
/** Абсолютный диапазон крена маршрута. */
val rollAngle: RouteAngleRangeDto,
/** Геометрия маршрута в WKT. */
@field:NotBlank(message = "Геометрия маршрута обязательна")
val geometry: String,
)
@@ -0,0 +1,69 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.OneToMany
import jakarta.persistence.Table
import org.hibernate.annotations.Fetch
import org.hibernate.annotations.FetchMode
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
@Entity
@Table(name = "earth_cells")
class EarthCellEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "cell_id")
var cellId: Long? = null,
@Column(name = "cell_num", nullable = false)
var cellNum: Long = 0,
@Column(name = "longitude")
var longitude: Double? = null,
@Column(name = "latitude")
var latitude: Double? = null,
@Column(name = "importance", nullable = false)
var importance: Double = 0.0,
@Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT")
var contour: String = "",
/** Привязанные к ячейке фрагменты заявок для legacy earth-grid API. */
@OneToMany(fetch = FetchType.LAZY, mappedBy = "cell", cascade = [CascadeType.PERSIST, CascadeType.MERGE], orphanRemoval = true)
@Fetch(FetchMode.SUBSELECT)
var requestProjections: MutableList<RequestCellEntity> = mutableListOf(),
) {
fun toDto(): EarthCellDTO {
val dto = EarthCellDTO(
id = cellId ?: 0,
num = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
)
return dto
}
fun toDtoWithRequests(): EarthCellWithRequestsDTO {
val dto = EarthCellWithRequestsDTO(
id = cellId ?: 0,
num = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
requests = requestProjections.map { requestProjection -> requestProjection.toDto() },
)
return dto
}
}
@@ -0,0 +1,22 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "earth_grid_settings")
class EarthGridSettingsEntity(
@Id
@Column(name = "settings_id")
var settingsId: Long = DEFAULT_SETTINGS_ID,
@Column(name = "calculation_step", nullable = false)
var calculationStep: Double = DEFAULT_CALCULATION_STEP,
) {
companion object {
const val DEFAULT_SETTINGS_ID = 1L
const val DEFAULT_CALCULATION_STEP = 2.0
}
}
@@ -0,0 +1,56 @@
package org.nstart.dep265.requestservice.entity
import com.fasterxml.jackson.databind.JsonNode
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(
name = "outbox_events",
uniqueConstraints = [
UniqueConstraint(
name = "uk_outbox_events_event_type_aggregate_id",
columnNames = ["event_type", "aggregate_id"],
),
],
)
class OutboxEventEntity(
@Id
@Column(name = "id", nullable = false)
var id: UUID,
@Column(name = "event_type", nullable = false, length = 128)
var eventType: String,
@Column(name = "aggregate_type", nullable = false, length = 64)
var aggregateType: String,
@Column(name = "aggregate_id", nullable = false)
var aggregateId: UUID,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload", nullable = false)
var payload: JsonNode,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
var status: OutboxEventStatus,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "published_at")
var publishedAt: LocalDateTime? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null,
)
@@ -0,0 +1,7 @@
package org.nstart.dep265.requestservice.entity
enum class OutboxEventStatus {
NEW,
PUBLISHED,
FAILED,
}
@@ -0,0 +1,50 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT
import java.util.UUID
@Entity
@Table(name = "request_cells")
class RequestCellEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
var id: Long? = null,
@Column(name = "request_id", nullable = false)
var requestId: UUID = UUID.randomUUID(),
@Column(name = "coverage_percent")
var coveragePercent: Double? = null,
/** Snapshot важности master-заявки, нужный для расчета importance ячейки. */
@Column(name = "importance", nullable = false)
var importance: Double = 0.0,
@Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT")
var contour: String = "",
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cell_id", nullable = false)
var cell: EarthCellEntity? = null,
) {
fun toDto(): CellRequestDOT {
val dto = CellRequestDOT(
id = id ?: 0,
requestId = requestId.toString(),
covPercent = coveragePercent ?: 0.0,
importance = importance,
contour = contour,
)
return dto
}
}
@@ -0,0 +1,89 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.CollectionTable
import jakarta.persistence.ElementCollection
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.Table
import jakarta.persistence.Version
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "requests")
class RequestEntity(
@Id
@Column(name = "id", nullable = false)
var id: UUID,
@Column(name = "name", nullable = false, length = 255)
var name: String,
@Column(name = "geometry", nullable = false, columnDefinition = "TEXT")
var geometry: String,
@Column(name = "remaining_geometry", nullable = false, columnDefinition = "TEXT")
var remainingGeometry: String,
@Column(name = "geometry_area", nullable = false)
var geometryArea: Double,
@Column(name = "remaining_area", nullable = false)
var remainingArea: Double,
@Column(name = "coverage_required_percent", nullable = false)
var coverageRequiredPercent: Double = 100.0,
@Column(name = "importance", nullable = false)
var importance: Double,
@Column(name = "begin_date_time", nullable = false)
var beginDateTime: LocalDateTime,
@Column(name = "end_date_time", nullable = false)
var endDateTime: LocalDateTime,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "request_kpp", joinColumns = [JoinColumn(name = "request_id")])
@Column(name = "kpp", nullable = false)
var kpp: MutableList<Int> = mutableListOf(),
@Column(name = "high_priority_transmit", nullable = false)
var highPriorityTransmit: Boolean = false,
@Enumerated(EnumType.STRING)
@Column(name = "survey_type", nullable = false, length = 32)
var surveyType: RequestSurveyType,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
var status: RequestStatus,
@Column(name = "match_count", nullable = false)
var matchCount: Int = 0,
@Column(name = "last_matched_at")
var lastMatchedAt: LocalDateTime? = null,
@Column(name = "completed_at")
var completedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime,
@Column(name = "deleted_at")
var deletedAt: LocalDateTime? = null,
/** Версия optimistic locking для безопасных конкурентных обновлений. */
@Version
@Column(name = "version", nullable = false)
var version: Long = 0,
)
@@ -0,0 +1,47 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID
@Entity
@Table(name = "request_optics_params")
class RequestOpticsParamsEntity(
@Id
@Column(name = "request_id", nullable = false)
var requestId: UUID,
@Enumerated(EnumType.STRING)
@Column(name = "result_type", nullable = false, length = 32)
var resultType: OpticsResultType,
@Column(name = "resolution", nullable = false)
var resolution: Double,
@Column(name = "sun_angle_min", nullable = false)
var sunAngleMin: Double = 10.0,
@Column(name = "sun_angle_max", nullable = false)
var sunAngleMax: Double = 90.0,
@Column(name = "clouds", nullable = false)
var clouds: Double = 100.0,
)
/**
* Результат оптической съемки.
*/
enum class OpticsResultType {
/** Панхроматическая съемка. */
PANCHROMATIC,
/** Мультиспектральная съемка. */
MULTISPECTRAL,
/** Паншарпенинг. */
PANSHARPENING,
}
@@ -0,0 +1,46 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "request_route_matches")
class RequestRouteMatchEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
var id: Long? = null,
@Column(name = "request_id", nullable = false)
var requestId: UUID,
@Column(name = "route_id", nullable = false)
var routeId: UUID,
@Column(name = "original_intersection_geometry", nullable = false, columnDefinition = "TEXT")
var originalIntersectionGeometry: String,
@Column(name = "original_intersection_area", nullable = false)
var originalIntersectionArea: Double,
@Column(name = "applied_intersection_geometry", columnDefinition = "TEXT")
var appliedIntersectionGeometry: String? = null,
@Column(name = "applied_intersection_area", nullable = false)
var appliedIntersectionArea: Double,
@Column(name = "coverage_delta_percent", nullable = false)
var coverageDeltaPercent: Double,
@Column(name = "contributes_to_coverage", nullable = false)
var contributesToCoverage: Boolean,
@Column(name = "matched_at", nullable = false)
var matchedAt: LocalDateTime,
)
@@ -0,0 +1,56 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID
@Entity
@Table(name = "request_rsa_params")
class RequestRsaParamsEntity(
@Id
@Column(name = "request_id", nullable = false)
var requestId: UUID,
@Enumerated(EnumType.STRING)
@Column(name = "result_type", nullable = false, length = 32)
var resultType: RsaResultType,
@Column(name = "interferometry", nullable = false)
var interferometry: Boolean = false,
@Enumerated(EnumType.STRING)
@Column(name = "polarisation", nullable = false, length = 8)
var polarisation: RsaPolarisation,
@Column(name = "resolution", nullable = false)
var resolution: Double,
)
/**
* Тип аппаратуры РСА.
*/
enum class RsaResultType {
/** Spotlight режим. */
SPOTLIGHT,
/** Stripmap режим. */
STRIPMAP,
/** ScanSAR режим. */
SCANSAR,
}
/**
* Тип поляризации РСА.
*/
enum class RsaPolarisation {
/** Горизонтальная передача и горизонтальный прием. */
HH,
/** Вертикальная передача и вертикальный прием. */
VV,
}
@@ -0,0 +1,21 @@
package org.nstart.dep265.requestservice.entity
/**
* Бизнес-статус заявки.
*/
enum class RequestStatus {
/** Заявка прошла валидацию и сохранена. */
ACCEPTED,
/** Заявка актуальна для обработки. */
ACTIVE,
/** Заявка выполнилась по проценту покрытия. */
COMPLETED,
/** Интервал заявки завершился до достижения требуемого покрытия. */
EXPIRED,
/** Заявка мягко удалена. */
DELETED,
}
@@ -0,0 +1,15 @@
package org.nstart.dep265.requestservice.entity
/**
* Вычисленный тип заявки по наличию optics/rsa.
*/
enum class RequestSurveyType {
/** В заявке указан только optics. */
OPTICS,
/** В заявке указан только rsa. */
RSA,
/** В заявке указаны optics и rsa. */
COMBINED,
}
@@ -0,0 +1,52 @@
package org.nstart.dep265.requestservice.kafka
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.validation.ConstraintViolationException
import jakarta.validation.Validator
import org.nstart.dep265.requestservice.mapper.RoutePassportMapper
import org.nstart.dep265.requestservice.service.RouteMatchingService
import org.slf4j.LoggerFactory
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
@Component
class RequestKafkaListener(
private val objectMapper: ObjectMapper,
private val validator: Validator,
private val routeMatchingService: RouteMatchingService,
private val routePassportMapper: RoutePassportMapper,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@KafkaListener(
topics = ["\${app.kafka.topics.route}"],
groupId = "\${spring.kafka.consumer.group-id}-route",
)
fun consumeRoute(message: String) {
try {
val routeMessage = objectMapper.readValue(
message,
object : TypeReference<KafkaMessage<RoutePassportDto>>() {},
)
val route = routePassportMapper.toRouteDto(routeMessage.data)
validate(route)
routeMatchingService.process(route)
} catch (exception: Exception) {
log.error("Failed to process Kafka route payload", exception)
throw exception
}
}
private fun validate(value: Any) {
val violations = validator.validate(value)
if (violations.isNotEmpty()) {
throw ConstraintViolationException(violations)
}
}
}
@@ -0,0 +1,36 @@
package org.nstart.dep265.requestservice.mapper
import org.nstart.dep265.requestservice.dto.RouteAngleRangeDto
import org.nstart.dep265.requestservice.dto.RouteDto
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import kotlin.math.abs
@Component
class RoutePassportMapper {
fun toRouteDto(routePassportDto: RoutePassportDto): RouteDto =
RouteDto(
routeId = routePassportDto.routeId,
intervalBegin = routePassportDto.intervalBegin,
intervalEnd = routePassportDto.intervalEnd,
rollAngle = routePassportDto.rollAngle.toAbsoluteRange(),
geometry = routePassportDto.geometry,
)
private fun AngleRangeDto.toAbsoluteRange(): RouteAngleRangeDto {
val minAbs = abs(min.toDouble())
val maxAbs = abs(max.toDouble())
val absoluteMin = if (min.signum() <= 0 && max.signum() >= 0) {
0.0
} else {
minOf(minAbs, maxAbs)
}
val absoluteMax = maxOf(minAbs, maxAbs)
return RouteAngleRangeDto(
min = absoluteMin,
max = absoluteMax,
)
}
}
@@ -0,0 +1,71 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.util.Optional
import java.util.UUID
interface EarthCellRepository : JpaRepository<EarthCellEntity, Long> {
@Query(
value = """
select earthCell
from EarthCellEntity earthCell
where (:minImportance is null or earthCell.importance > :minImportance)
""",
countQuery = """
select count(earthCell)
from EarthCellEntity earthCell
where (:minImportance is null or earthCell.importance > :minImportance)
"""
)
fun findCells(
@Param("minImportance") minImportance: Double?,
pageable: Pageable,
): Page<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query(
"""
select distinct earthCell
from EarthCellEntity earthCell
where (:minImportance is null or earthCell.importance > :minImportance)
order by earthCell.importance desc, earthCell.cellNum asc
"""
)
fun findCellsWithRequestProjections(
@Param("minImportance") minImportance: Double?,
): List<EarthCellEntity>
@Query(
value = "SELECT * FROM earth_cells WHERE ST_Intersects(ST_GeomFromText(:polygon, 4326), contour_geom)",
nativeQuery = true,
)
fun findIntersectingByContour(@Param("polygon") polygonWkt: String): List<EarthCellEntity>
@Query(
value = "SELECT * FROM earth_cells WHERE cell_id IN (SELECT cell_id FROM request_cells WHERE request_id = :requestId)",
nativeQuery = true,
)
fun findByRequestId(@Param("requestId") requestId: UUID): List<EarthCellEntity>
fun findByCellNum(cellNum: Long): Optional<EarthCellEntity>
fun findAllByImportanceGreaterThanOrderByImportanceDesc(importance: Double): List<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query("select distinct earthCell from EarthCellEntity earthCell where earthCell.importance > :importance order by earthCell.importance desc")
fun findDetailedByImportanceGreaterThanOrderByImportanceDesc(@Param("importance") importance: Double): List<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query("select earthCell from EarthCellEntity earthCell where earthCell.cellId = :cellId")
fun findWithRequestProjectionsByCellId(@Param("cellId") cellId: Long): Optional<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query("select earthCell from EarthCellEntity earthCell where earthCell.cellNum = :cellNum")
fun findWithRequestProjectionsByCellNum(@Param("cellNum") cellNum: Long): Optional<EarthCellEntity>
}
@@ -0,0 +1,6 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.springframework.data.jpa.repository.JpaRepository
interface EarthGridSettingsRepository : JpaRepository<EarthGridSettingsEntity, Long>
@@ -0,0 +1,83 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
import java.util.UUID
interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
fun existsByEventTypeAndAggregateId(eventType: String, aggregateId: UUID): Boolean
fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List<OutboxEventEntity>
/**
* Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already
* being published by another transaction.
*/
@Query(
value = """
SELECT *
FROM outbox_events
WHERE event_type = :eventType
AND status IN ('NEW', 'FAILED')
ORDER BY
CASE WHEN status = 'NEW' THEN 0 ELSE 1 END,
created_at ASC
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true,
)
fun findRequestCompletedPublishableForUpdateSkipLocked(
@Param("eventType") eventType: String,
@Param("batchSize") batchSize: Int,
): List<OutboxEventEntity>
/**
* Inserts a new outbox event atomically and treats the event_type + aggregate_id conflict as
* an idempotent duplicate. Production PostgreSQL stores payload as JSONB.
*/
@Modifying
@Query(
value = """
INSERT INTO outbox_events (
id,
event_type,
aggregate_type,
aggregate_id,
payload,
status,
created_at,
published_at,
error_message
)
VALUES (
:id,
:eventType,
:aggregateType,
:aggregateId,
CAST(:payload AS jsonb),
:status,
:createdAt,
NULL,
NULL
)
ON CONFLICT (event_type, aggregate_id) DO NOTHING
""",
nativeQuery = true,
)
fun insertIfAbsent(
@Param("id") id: UUID,
@Param("eventType") eventType: String,
@Param("aggregateType") aggregateType: String,
@Param("aggregateId") aggregateId: UUID,
@Param("payload") payload: String,
@Param("status") status: String,
@Param("createdAt") createdAt: LocalDateTime,
): Int
}
@@ -0,0 +1,30 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
interface RequestCellRepository : JpaRepository<RequestCellEntity, Long> {
@Modifying
@Transactional
@Query("delete from RequestCellEntity requestCell where requestCell.requestId = :requestId")
fun deleteByRequestId(@Param("requestId") requestId: UUID)
fun findByRequestId(requestId: UUID): List<RequestCellEntity>
@EntityGraph(attributePaths = ["cell"])
@Query(
"""
select requestCell
from RequestCellEntity requestCell
where requestCell.requestId = :requestId
order by requestCell.id asc
"""
)
fun findWithCellByRequestId(@Param("requestId") requestId: UUID): List<RequestCellEntity>
}
@@ -0,0 +1,7 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RequestOpticsParamsRepository : JpaRepository<RequestOpticsParamsEntity, UUID>
@@ -0,0 +1,109 @@
package org.nstart.dep265.requestservice.repository
import jakarta.persistence.LockModeType
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
interface RequestRepository : JpaRepository<RequestEntity, UUID> {
@Query(
value = """
select distinct request
from RequestEntity request
left join request.kpp requestKpp
where (:includeDeleted = true or request.status <> :deletedStatus)
and (:status is null or request.status = :status)
and (:surveyType is null or request.surveyType = :surveyType)
and (:kpp is null or requestKpp = :kpp)
and (:highPriorityTransmit is null or request.highPriorityTransmit = :highPriorityTransmit)
and (cast(:beginFrom as LocalDateTime) is null or request.beginDateTime >= :beginFrom)
and (cast(:beginTo as LocalDateTime) is null or request.beginDateTime <= :beginTo)
and (cast(:endFrom as LocalDateTime) is null or request.endDateTime >= :endFrom)
and (cast(:endTo as LocalDateTime) is null or request.endDateTime <= :endTo)
""",
countQuery = """
select count(distinct request)
from RequestEntity request
left join request.kpp requestKpp
where (:includeDeleted = true or request.status <> :deletedStatus)
and (:status is null or request.status = :status)
and (:surveyType is null or request.surveyType = :surveyType)
and (:kpp is null or requestKpp = :kpp)
and (:highPriorityTransmit is null or request.highPriorityTransmit = :highPriorityTransmit)
and (cast(:beginFrom as LocalDateTime) is null or request.beginDateTime >= :beginFrom)
and (cast(:beginTo as LocalDateTime) is null or request.beginDateTime <= :beginTo)
and (cast(:endFrom as LocalDateTime) is null or request.endDateTime >= :endFrom)
and (cast(:endTo as LocalDateTime) is null or request.endDateTime <= :endTo)
"""
)
fun findRequests(
@Param("includeDeleted") includeDeleted: Boolean,
@Param("deletedStatus") deletedStatus: RequestStatus,
@Param("status") status: RequestStatus?,
@Param("surveyType") surveyType: RequestSurveyType?,
@Param("kpp") kpp: Int?,
@Param("highPriorityTransmit") highPriorityTransmit: Boolean?,
@Param("beginFrom") beginFrom: LocalDateTime?,
@Param("beginTo") beginTo: LocalDateTime?,
@Param("endFrom") endFrom: LocalDateTime?,
@Param("endTo") endTo: LocalDateTime?,
pageable: Pageable,
): Page<RequestEntity>
/**
* Возвращает идентификаторы активных заявок, окно которых пересекается с окном маршрута.
*/
@Query(
"""
select request.id
from RequestEntity request
where request.status in :statuses
and request.beginDateTime <= :routeEnd
and request.endDateTime >= :routeBegin
and request.deletedAt is null
"""
)
fun findRequestIdsForRoute(
@Param("statuses") statuses: Collection<RequestStatus>,
@Param("routeBegin") routeBegin: LocalDateTime,
@Param("routeEnd") routeEnd: LocalDateTime,
): List<UUID>
/**
* Сериализует применение маршрута к одной заявке через row-level lock.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"""
select request
from RequestEntity request
where request.id = :id
"""
)
fun findByIdForUpdate(@Param("id") id: UUID): Optional<RequestEntity>
@Query(
"""
select request
from RequestEntity request
where request.beginDateTime < :selectionEnd
and :selectionStart < request.endDateTime
and request.deletedAt is null
"""
)
fun findRequestsIntersectingSelectionInterval(
@Param("selectionStart") selectionStart: LocalDateTime,
@Param("selectionEnd") selectionEnd: LocalDateTime,
): List<RequestEntity>
fun findByStatusIn(statuses: Collection<RequestStatus>): List<RequestEntity>
}
@@ -0,0 +1,11 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RequestRouteMatchRepository : JpaRepository<RequestRouteMatchEntity, Long> {
fun existsByRequestIdAndRouteId(requestId: UUID, routeId: UUID): Boolean
fun deleteByRequestId(requestId: UUID)
}
@@ -0,0 +1,7 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RequestRsaParamsRepository : JpaRepository<RequestRsaParamsEntity, UUID>
@@ -0,0 +1,206 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.dto.CellRequestFragmentResponseDto
import org.nstart.dep265.requestservice.dto.CellSummaryResponseDto
import org.nstart.dep265.requestservice.dto.CellWithRequestsResponseDto
import org.nstart.dep265.requestservice.dto.CellsListResponseDto
import org.nstart.dep265.requestservice.dto.CellsWithRequestsResponseDto
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
import java.util.UUID
class InvalidCellsQueryException(message: String) : IllegalArgumentException(message)
@Service
class CellsQueryService(
private val earthCellRepository: EarthCellRepository,
private val earthGridCatalogService: EarthGridCatalogService,
) {
@Transactional(readOnly = true)
fun listCells(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
page: Int,
size: Int,
sort: String?,
): CellsListResponseDto {
validateQuery(minImportance, countLat, countLong, page, size)
val cells = earthCellRepository.findCells(
minImportance = minImportance,
pageable = PageRequest.of(page, size, sort.toCellsSort()),
)
return CellsListResponseDto(
items = cells.content.map { earthCell -> earthCell.toSummaryResponse() },
page = page,
size = size,
totalItems = cells.totalElements,
totalPages = cells.totalPages,
)
}
@Transactional(readOnly = true)
fun listCellsWithRequests(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
): CellsWithRequestsResponseDto {
validateWithRequestsQuery(minImportance, countLat, countLong)
val items = if (countLat == null && countLong == null) {
earthCellRepository.findCellsWithRequestProjections(minImportance)
.map { earthCell -> earthCell.toWithRequestsResponse() }
} else {
earthGridCatalogService.allWithRequests(
minImportance = minImportance,
countLat = countLat,
countLong = countLong,
)
.map { earthCell -> earthCell.toWithRequestsResponse() }
}
return CellsWithRequestsResponseDto(items = items)
}
private fun validateQuery(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
page: Int,
size: Int,
) {
if (minImportance != null && minImportance < 0.0) {
throw InvalidCellsQueryException("minImportance must be greater than or equal to 0")
}
if (countLat != null && countLat <= 0) {
throw InvalidCellsQueryException("countLat must be greater than 0")
}
if (countLong != null && countLong <= 0) {
throw InvalidCellsQueryException("countLong must be greater than 0")
}
if (countLat != null || countLong != null) {
throw InvalidCellsQueryException("Cell aggregation by countLat/countLong is not implemented yet")
}
if (page < 0) {
throw InvalidCellsQueryException("page must be greater than or equal to 0")
}
if (size < 1 || size > 500) {
throw InvalidCellsQueryException("size must be between 1 and 500")
}
}
private fun validateWithRequestsQuery(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
) {
if (minImportance != null && minImportance < 0.0) {
throw InvalidCellsQueryException("minImportance must be greater than or equal to 0")
}
if (countLat != null && countLat <= 0) {
throw InvalidCellsQueryException("countLat must be greater than 0")
}
if (countLong != null && countLong <= 0) {
throw InvalidCellsQueryException("countLong must be greater than 0")
}
if ((countLat == null) != (countLong == null)) {
throw InvalidCellsQueryException("countLat and countLong must be provided together")
}
}
private fun String?.toCellsSort(): Sort {
if (isNullOrBlank()) {
return DEFAULT_SORT
}
val parts = split(",").map { part -> part.trim() }.filter { part -> part.isNotEmpty() }
val field = parts.firstOrNull() ?: DEFAULT_SORT_FIELD
val direction = parts.getOrNull(1)?.let { value ->
when (value.lowercase()) {
"asc" -> Sort.Direction.ASC
"desc" -> Sort.Direction.DESC
else -> throw InvalidCellsQueryException("Unsupported sort direction: $value")
}
} ?: Sort.Direction.ASC
val property = ALLOWED_SORT_FIELDS[field]
?: throw InvalidCellsQueryException("Unsupported sort field: $field")
val requestedSort = Sort.by(direction, property)
return if (property == "cellNum") {
requestedSort
} else {
requestedSort.and(Sort.by(Sort.Direction.ASC, "cellNum"))
}
}
private fun EarthCellEntity.toSummaryResponse(): CellSummaryResponseDto {
return CellSummaryResponseDto(
cellNum = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
)
}
private fun EarthCellEntity.toWithRequestsResponse(): CellWithRequestsResponseDto {
return CellWithRequestsResponseDto(
cellNum = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
requests = requestProjections.map { requestProjection -> requestProjection.toFragmentResponse() },
)
}
private fun RequestCellEntity.toFragmentResponse(): CellRequestFragmentResponseDto {
return CellRequestFragmentResponseDto(
requestId = requestId,
contour = contour,
coveragePercent = coveragePercent ?: 0.0,
importance = importance,
)
}
private fun EarthCellWithRequestsDTO.toWithRequestsResponse(): CellWithRequestsResponseDto {
return CellWithRequestsResponseDto(
cellNum = num,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = contour,
requests = requests.map { request -> request.toFragmentResponse() },
)
}
private fun CellRequestDOT.toFragmentResponse(): CellRequestFragmentResponseDto {
return CellRequestFragmentResponseDto(
requestId = UUID.fromString(requestId),
contour = contour,
coveragePercent = covPercent,
importance = importance,
)
}
private companion object {
const val DEFAULT_SORT_FIELD = "importance"
val DEFAULT_SORT: Sort = Sort.by(Sort.Direction.DESC, "importance")
.and(Sort.by(Sort.Direction.ASC, "cellNum"))
val ALLOWED_SORT_FIELDS: Map<String, String> = mapOf(
"cellNum" to "cellNum",
"importance" to "importance",
"latitude" to "latitude",
"longitude" to "longitude",
)
}
}
@@ -0,0 +1,40 @@
package org.nstart.dep265.requestservice.service
import org.geotools.geometry.jts.JTS
import org.geotools.referencing.CRS
import org.springframework.stereotype.Service
@Service
class CompatGeometryService(
private val geometryService: GeometryService,
) {
fun calculateAreaSqKm(wkt: String): Double {
/** Исходная геометрия берется в WGS84, как и в legacy earth-grid сервисе. */
val geometry = geometryService.parseGeometry(wkt)
val sourceCrs = CRS.decode("EPSG:4326")
/** Проекция World Mollweide используется для стабильного расчета площади. */
val targetCrs = CRS.decode("EPSG:54009")
val transform = CRS.findMathTransform(sourceCrs, targetCrs)
val projectedGeometry = JTS.transform(geometry, transform)
val areaSqKm = projectedGeometry.area / 1_000_000.0
return areaSqKm
}
fun toGeometryCollection(wkts: Iterable<String>): String {
/** Ненулевые геометрии фильтруются, чтобы не строить битый WKT. */
val nonBlankWkts = wkts.filter { wkt -> wkt.isNotBlank() }.toList()
if (nonBlankWkts.isEmpty()) {
return ""
}
val geometryCollection = buildString {
append("GEOMETRYCOLLECTION(")
append(nonBlankWkts.joinToString(","))
append(")")
}
return geometryCollection
}
}
@@ -0,0 +1,271 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.truncate
@Service
class EarthGridCatalogService(
private val earthCellRepository: EarthCellRepository,
private val earthGridSettingsRepository: EarthGridSettingsRepository,
private val compatGeometryService: CompatGeometryService,
@Value("\${app.grid.enabled:true}")
private val gridEnabled: Boolean,
) {
private data class AggregatedGridKey(
val latitudeGroup: Long,
val longitudeGroup: Long,
)
@Transactional(readOnly = true)
fun all(countLat: Int? = null, countLong: Int? = null): List<EarthCellDTO> {
val earthCells = earthCellRepository.findAll()
return aggregateIfRequested(earthCells, countLat, countLong)
}
@Transactional(readOnly = true)
fun paintByImportance(importance: Double): String {
val earthCells = earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(importance)
/** Контуры ячеек складываются в GEOMETRYCOLLECTION для legacy paint endpoint. */
val geometryCollection = compatGeometryService.toGeometryCollection(earthCells.map { earthCell -> earthCell.contour })
return geometryCollection
}
@Transactional(readOnly = true)
fun byImportance(
importance: Double,
countLat: Int? = null,
countLong: Int? = null,
): List<EarthCellDTO> {
val earthCells = earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(importance)
return aggregateIfRequested(earthCells, countLat, countLong)
}
@Transactional(readOnly = true)
fun allWithRequests(
minImportance: Double? = null,
countLat: Int? = null,
countLong: Int? = null,
): List<EarthCellWithRequestsDTO> {
val earthCells = earthCellRepository.findCellsWithRequestProjections(minImportance)
return aggregateWithRequestsIfRequested(earthCells, countLat, countLong)
}
@Transactional(readOnly = true)
fun byId(cellId: Long) = earthCellRepository.findWithRequestProjectionsByCellId(cellId)
.map { earthCell -> earthCell.toDtoWithRequests() }
@Transactional(readOnly = true)
fun byNum(cellNum: Long) = earthCellRepository.findWithRequestProjectionsByCellNum(cellNum)
.map { earthCell -> earthCell.toDtoWithRequests() }
@Transactional(readOnly = true)
fun byNumForPaint(cellNum: Long): String {
val earthCell = earthCellRepository.findWithRequestProjectionsByCellNum(cellNum).orElse(null) ?: return ""
/** Контур самой ячейки должен идти первым, как это делал старый earth-grid. */
val wkts = mutableListOf<String>()
wkts.add(earthCell.contour)
wkts.addAll(earthCell.requestProjections.map { requestProjection -> requestProjection.contour })
val geometryCollection = compatGeometryService.toGeometryCollection(wkts)
return geometryCollection
}
@Transactional
fun init(step: Double) {
require(step > 0.0) { "Шаг сетки должен быть больше 0" }
updateCalculationStep(step)
if (!gridEnabled) {
return
}
/** Все старые ячейки удаляются, потому что init — это полный rebuild каталога. */
earthCellRepository.deleteAllInBatch()
/** Текущая широта стартует по правилам old earth-grid сервиса. */
var latitude = -90 + step
while (latitude <= 90) {
val line = mutableListOf<EarthCellEntity>()
var longitude = 0.0
while (longitude < (360-0.001)) {
/** Номер ячейки вычисляется legacy-формулой через индексы b/l. */
val cellNum = getInd(latitude * PI / 180.0, longitude * PI / 180.0)
/** Контур повторяет старую геометрию earth-grid 1x1 градус. */
val contour = "POLYGON (($longitude $latitude, ${longitude - step} $latitude, ${longitude - step} ${latitude - step}, $longitude ${latitude - step}, $longitude $latitude))"
val earthCell = EarthCellEntity(
cellId = null,
cellNum = cellNum,
longitude = longitude,
latitude = latitude,
importance = 0.0,
contour = contour,
)
line.add(earthCell)
longitude += step
}
earthCellRepository.saveAll(line)
latitude += step
}
}
private fun updateCalculationStep(step: Double) {
val settings = earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
.orElseGet {
EarthGridSettingsEntity(
settingsId = EarthGridSettingsEntity.DEFAULT_SETTINGS_ID,
calculationStep = EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP,
)
}
settings.calculationStep = step
earthGridSettingsRepository.save(settings)
}
private fun aggregateIfRequested(
earthCells: List<EarthCellEntity>,
countLat: Int?,
countLong: Int?,
): List<EarthCellDTO> {
if (countLat == null && countLong == null) {
return earthCells.map { earthCell -> earthCell.toDto() }
}
val latitudeCount = countLat ?: 1
val longitudeCount = countLong ?: 1
require(latitudeCount > 0) { "Параметр countLat должен быть больше 0" }
require(longitudeCount > 0) { "Параметр countLong должен быть больше 0" }
val calculationStep = currentCalculationStep()
return earthCells
.groupBy { earthCell -> aggregationKey(earthCell, calculationStep, latitudeCount, longitudeCount) }
.values
.map { group -> aggregateCells(group, calculationStep) }
.sortedWith(compareByDescending<EarthCellDTO> { it.importance }.thenBy { it.num })
}
private fun aggregateWithRequestsIfRequested(
earthCells: List<EarthCellEntity>,
countLat: Int?,
countLong: Int?,
): List<EarthCellWithRequestsDTO> {
if (countLat == null && countLong == null) {
return earthCells.map { earthCell -> earthCell.toDtoWithRequests() }
}
val latitudeCount = countLat ?: 1
val longitudeCount = countLong ?: 1
require(latitudeCount > 0) { "Параметр countLat должен быть больше 0" }
require(longitudeCount > 0) { "Параметр countLong должен быть больше 0" }
val calculationStep = currentCalculationStep()
return earthCells
.groupBy { earthCell -> aggregationKey(earthCell, calculationStep, latitudeCount, longitudeCount) }
.values
.map { group -> aggregateCellsWithRequests(group, calculationStep) }
.sortedWith(compareByDescending<EarthCellWithRequestsDTO> { it.importance }.thenBy { it.num })
}
private fun currentCalculationStep(): Double =
earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
.map { settings -> settings.calculationStep }
.orElse(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP)
private fun aggregationKey(
earthCell: EarthCellEntity,
calculationStep: Double,
countLat: Int,
countLong: Int,
): AggregatedGridKey {
val latitude = earthCell.latitude ?: 0.0
val longitude = earthCell.longitude ?: 0.0
val latitudeIndex = floor((latitude - (-90.0 + calculationStep)) / calculationStep).toLong().coerceAtLeast(0L)
val longitudeIndex = floor(longitude / calculationStep).toLong().coerceAtLeast(0L)
return AggregatedGridKey(
latitudeGroup = latitudeIndex / countLat,
longitudeGroup = longitudeIndex / countLong,
)
}
private fun aggregateCells(
earthCells: List<EarthCellEntity>,
calculationStep: Double,
): EarthCellDTO {
val minLongitude = earthCells.minOf { earthCell -> (earthCell.longitude ?: 0.0) - calculationStep }
val maxLongitude = earthCells.maxOf { earthCell -> earthCell.longitude ?: 0.0 }
val minLatitude = earthCells.minOf { earthCell -> (earthCell.latitude ?: 0.0) - calculationStep }
val maxLatitude = earthCells.maxOf { earthCell -> earthCell.latitude ?: 0.0 }
val importance = earthCells.sumOf { earthCell -> earthCell.importance }
val firstCell = earthCells.minWith(compareBy<EarthCellEntity> { it.latitude ?: 0.0 }.thenBy { it.longitude ?: 0.0 })
val contour = "POLYGON (($minLongitude $minLatitude, $maxLongitude $minLatitude, $maxLongitude $maxLatitude, $minLongitude $maxLatitude, $minLongitude $minLatitude))"
return EarthCellDTO(
id = firstCell.cellId ?: firstCell.cellNum,
num = firstCell.cellNum,
latitude = (minLatitude + maxLatitude) / 2.0,
longitude = (minLongitude + maxLongitude) / 2.0,
importance = importance,
contour = contour,
)
}
private fun aggregateCellsWithRequests(
earthCells: List<EarthCellEntity>,
calculationStep: Double,
): EarthCellWithRequestsDTO {
val aggregate = aggregateCells(earthCells, calculationStep)
return EarthCellWithRequestsDTO(
id = aggregate.id,
num = aggregate.num,
latitude = aggregate.latitude,
longitude = aggregate.longitude,
importance = aggregate.importance,
contour = aggregate.contour,
requests = earthCells.flatMap { earthCell -> earthCell.requestProjections.map { it.toDto() } },
)
}
fun getBInd(latitudeRadians: Double): Long {
var latitudeDegrees = latitudeRadians * 180.0 / PI
latitudeDegrees = 90.0 - latitudeDegrees
var index = truncate(latitudeDegrees).toLong()
if (index > 180) {
index = 180
}
return index
}
fun getLInd(longitudeRadians: Double): Long {
var longitudeDegrees = longitudeRadians * 180.0 / PI
if (longitudeDegrees < 0) {
longitudeDegrees += 360.0
}
var index = truncate(longitudeDegrees).toLong()
if (index > 359) {
index = 359
}
return index
}
fun getInd(latitudeRadians: Double, longitudeRadians: Double): Long {
/** Итоговый номер повторяет алгоритм old earth-grid сервиса. */
val cellNum = getBInd(latitudeRadians) * 360 + getLInd(longitudeRadians)
return cellNum
}
}
@@ -0,0 +1,118 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryCollection
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import org.locationtech.jts.io.WKTWriter
import org.springframework.stereotype.Service
import java.io.StringReader
import kotlin.math.max
import kotlin.math.min
@Service
class GeometryService {
private val wktReader = WKTReader()
private val wktWriter = WKTWriter()
fun parseGeometry(wkt: String): Geometry {
val geometry = wktReader.read(StringReader(wkt))
return geometry
}
fun parsePolygonalGeometry(wkt: String): Geometry {
val geometry = parseGeometry(wkt)
return keepPolygonalGeometry(geometry)
}
fun toWkt(geometry: Geometry): String {
val wkt = wktWriter.write(geometry)
return wkt
}
fun area(geometry: Geometry): Double {
val area = geometry.area
return area
}
fun cutCoverage(remainingGeometryWkt: String, routeGeometryWkt: String): GeometryCutResult {
val remainingGeometry = parsePolygonalGeometry(remainingGeometryWkt)
val routeGeometry = parsePolygonalGeometry(routeGeometryWkt)
val intersection = keepPolygonalGeometry(remainingGeometry.intersection(routeGeometry))
val coveredArea = intersection.area
if (coveredArea <= 0.0) {
val result = GeometryCutResult(
remainingGeometryWkt = toWkt(remainingGeometry),
remainingArea = remainingGeometry.area,
coveredArea = 0.0,
hasCoverage = false,
)
return result
}
val updatedRemainingGeometry = keepPolygonalGeometry(remainingGeometry.difference(routeGeometry))
val result = GeometryCutResult(
remainingGeometryWkt = toWkt(updatedRemainingGeometry),
remainingArea = max(updatedRemainingGeometry.area, 0.0),
coveredArea = coveredArea,
hasCoverage = true,
)
return result
}
fun coveredPercent(originalArea: Double, remainingArea: Double): Double {
if (originalArea <= 0.0) {
return 0.0
}
val coveredArea = max(originalArea - remainingArea, 0.0)
val coveredPercent = (coveredArea / originalArea) * 100.0
return min(max(coveredPercent, 0.0), 100.0)
}
fun keepPolygonalGeometry(geometry: Geometry): Geometry {
val polygonalGeometry = when (geometry) {
is Polygon -> geometry
is MultiPolygon -> geometry
is GeometryCollection -> {
val polygons = mutableListOf<Polygon>()
collectPolygons(geometry, polygons)
when {
polygons.isEmpty() -> geometry.factory.createPolygon()
polygons.size == 1 -> polygons.first()
else -> geometry.factory.createMultiPolygon(polygons.toTypedArray())
}
}
else -> geometry.factory.createPolygon()
}
return polygonalGeometry
}
private fun collectPolygons(geometry: Geometry, polygons: MutableList<Polygon>) {
when (geometry) {
is Polygon -> if (!geometry.isEmpty) {
polygons.add(geometry)
}
is MultiPolygon,
is GeometryCollection -> {
for (index in 0 until geometry.numGeometries) {
val nestedGeometry = geometry.getGeometryN(index)
collectPolygons(nestedGeometry, polygons)
}
}
}
}
}
data class GeometryCutResult(
val remainingGeometryWkt: String,
val remainingArea: Double,
val coveredArea: Double,
val hasCoverage: Boolean,
)
@@ -0,0 +1,21 @@
package org.nstart.dep265.requestservice.service
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
@Service
class GridRebuildLockService(
private val jdbcTemplate: JdbcTemplate,
) {
fun tryAcquireTransactionLock(): Boolean {
return jdbcTemplate.queryForObject(
"select pg_try_advisory_xact_lock(?)",
Boolean::class.java,
GRID_REBUILD_LOCK_KEY,
) == true
}
private companion object {
const val GRID_REBUILD_LOCK_KEY = 26_501_001L
}
}
@@ -0,0 +1,51 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime
import java.time.ZoneOffset
class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running")
@Service
class GridRebuildService(
private val gridSettingsService: GridSettingsService,
private val earthGridCatalogService: EarthGridCatalogService,
private val requestCellRepository: RequestCellRepository,
private val requestRepository: RequestRepository,
private val requestGridProjectionService: RequestGridProjectionService,
private val earthCellRepository: EarthCellRepository,
private val gridRebuildLockService: GridRebuildLockService,
) {
@Transactional
fun rebuildGrid(): GridRebuildResponseDto {
if (!gridRebuildLockService.tryAcquireTransactionLock()) {
throw GridRebuildAlreadyRunningException()
}
val settings = gridSettingsService.getSettings()
val activeRequests = requestRepository.findByStatusIn(ACTIVE_REQUEST_STATUSES)
requestCellRepository.deleteAllInBatch()
earthGridCatalogService.init(settings.calculationStep)
activeRequests.forEach { request ->
requestGridProjectionService.rebuildProjection(request)
}
return GridRebuildResponseDto(
cellsCount = earthCellRepository.count(),
rebuiltRequestProjectionsCount = activeRequests.size.toLong(),
rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC),
)
}
private companion object {
val ACTIVE_REQUEST_STATUSES = setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE)
}
}
@@ -0,0 +1,57 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.dto.GridSettingsResponseDto
import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
class InvalidGridSettingsException(message: String) : IllegalArgumentException(message)
@Service
class GridSettingsService(
private val earthGridSettingsRepository: EarthGridSettingsRepository,
) {
@Transactional
fun getSettings(): GridSettingsResponseDto {
return getOrCreateSettings().toResponse()
}
@Transactional
fun updateSettings(request: UpdateGridSettingsRequestDto): GridSettingsResponseDto {
val calculationStep = request.calculationStep
?: throw InvalidGridSettingsException("calculationStep must be provided")
validateCalculationStep(calculationStep)
val settings = getOrCreateSettings()
settings.calculationStep = calculationStep
return earthGridSettingsRepository.save(settings).toResponse()
}
private fun getOrCreateSettings(): EarthGridSettingsEntity {
return earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
.orElseGet {
earthGridSettingsRepository.save(
EarthGridSettingsEntity(
settingsId = EarthGridSettingsEntity.DEFAULT_SETTINGS_ID,
calculationStep = EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP,
)
)
}
}
private fun validateCalculationStep(calculationStep: Double) {
if (calculationStep <= 0.0) {
throw InvalidGridSettingsException("calculationStep must be greater than 0")
}
}
private fun EarthGridSettingsEntity.toResponse(): GridSettingsResponseDto {
return GridSettingsResponseDto(
settingsId = settingsId,
calculationStep = calculationStep,
)
}
}
@@ -0,0 +1,175 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.CoordinateSequenceFilter
import org.locationtech.jts.geom.Envelope
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryCollection
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.LinearRing
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import kotlin.math.abs
import kotlin.math.floor
/**
* Утилита нормализации долгот для геометрий, переходящих через нулевой меридиан.
*/
object LongitudeWrapGeometry {
private val geometryFactory = GeometryFactory()
/**
* Переводит геометрию в непрерывный диапазон долгот вокруг [0..360].
*/
fun normalizeToContinuous360(geometry: Geometry): Geometry {
val normalized = shiftNegativeLongitudesTo360(geometry.copy())
return when (normalized) {
is Polygon -> normalizePolygon(normalized)
is MultiPolygon -> normalizeMultiPolygon(normalized)
else -> normalized
}.let { candidate -> if (candidate.isValid) candidate else candidate.buffer(0.0) }
}
/**
* Строит поисковые варианты geometry в соседних полосах долгот.
*/
fun buildSearchVariants(geometry: Geometry): List<Geometry> {
val normalized = normalizeToContinuous360(geometry)
return listOf(
shiftLongitude(normalized, -360.0),
normalized,
shiftLongitude(normalized, 360.0),
).distinctBy { variant -> variant.norm().toText() }
}
/**
* Выравнивает геометрию к полосе долгот опорной геометрии.
*/
fun alignToReference(geometry: Geometry, reference: Geometry): Geometry {
val normalizedReference = normalizeToContinuous360(reference)
val referenceCenter = envelopeCenterX(normalizedReference.envelopeInternal)
return buildSearchVariants(geometry)
.minByOrNull { candidate -> abs(envelopeCenterX(candidate.envelopeInternal) - referenceCenter) }
?: normalizeToContinuous360(geometry)
}
private fun shiftNegativeLongitudesTo360(geometry: Geometry): Geometry {
geometry.apply(object : CoordinateSequenceFilter {
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
val longitude = seq.getX(i)
if (longitude < 0.0) {
seq.setOrdinate(i, 0, longitude + 360.0)
}
}
override fun isDone(): Boolean = false
override fun isGeometryChanged(): Boolean = true
})
geometry.geometryChanged()
return geometry
}
private fun normalizePolygon(polygon: Polygon): Geometry {
val shell = unwrapRing(polygon.exteriorRing)
val holes = Array(polygon.numInteriorRing) { index ->
unwrapRing(polygon.getInteriorRingN(index))
}
return shiftIntoCanonicalBand(repairGeometry(polygon.factory.createPolygon(shell, holes)))
}
private fun normalizeMultiPolygon(multiPolygon: MultiPolygon): Geometry {
val parts = mutableListOf<Polygon>()
for (index in 0 until multiPolygon.numGeometries) {
collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon), parts)
}
return when (parts.size) {
0 -> geometryFactory.createPolygon()
1 -> parts.first()
else -> geometryFactory.createMultiPolygon(parts.toTypedArray())
}
}
private fun collectPolygons(geometry: Geometry, parts: MutableList<Polygon>) {
when (geometry) {
is Polygon -> parts += geometry
is MultiPolygon -> {
for (index in 0 until geometry.numGeometries) {
parts += geometry.getGeometryN(index) as Polygon
}
}
is GeometryCollection -> {
for (index in 0 until geometry.numGeometries) {
collectPolygons(geometry.getGeometryN(index), parts)
}
}
}
}
private fun repairGeometry(geometry: Geometry): Geometry =
if (geometry.isValid) geometry else geometry.buffer(0.0)
private fun shiftIntoCanonicalBand(geometry: Geometry): Geometry {
val minX = geometry.coordinates.minOfOrNull { coordinate -> coordinate.x } ?: return geometry
if (minX >= 0.0) {
return geometry
}
val shift = 360.0 * (floor(-minX / 360.0) + 1.0)
return shiftLongitude(geometry, shift)
}
private fun shiftLongitude(geometry: Geometry, delta: Double): Geometry {
if (delta == 0.0) {
return geometry
}
val shifted = geometry.copy()
shifted.apply(object : CoordinateSequenceFilter {
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
seq.setOrdinate(i, 0, seq.getX(i) + delta)
}
override fun isDone(): Boolean = false
override fun isGeometryChanged(): Boolean = true
})
shifted.geometryChanged()
return shifted
}
private fun envelopeCenterX(envelope: Envelope): Double = (envelope.minX + envelope.maxX) / 2.0
private fun unwrapRing(ring: org.locationtech.jts.geom.LineString): LinearRing {
val source = ring.coordinates
if (source.isEmpty()) {
return ring.factory.createLinearRing()
}
val first = source.first()
val unwrapped = ArrayList<Coordinate>(source.size)
var previousX = first.x
unwrapped.add(Coordinate(previousX, first.y, first.z))
for (index in 1 until source.size - 1) {
val current = source[index]
var adjustedX = current.x
while (adjustedX - previousX > 180.0) {
adjustedX -= 360.0
}
while (adjustedX - previousX < -180.0) {
adjustedX += 360.0
}
unwrapped.add(Coordinate(adjustedX, current.y, current.z))
previousX = adjustedX
}
val start = unwrapped.first()
unwrapped.add(Coordinate(start.x, start.y, start.z))
return ring.factory.createLinearRing(unwrapped.toTypedArray())
}
}
@@ -0,0 +1,114 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.config.OutboxProperties
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.util.concurrent.ExecutionException
@Service
class OutboxPublisherService(
private val outboxEventRepository: OutboxEventRepository,
private val kafkaTemplate: KafkaTemplate<String, String>,
private val outboxProperties: OutboxProperties,
@param:Qualifier("outboxTransactionOperations")
private val transactionOperations: TransactionOperations,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Scheduled(fixedDelayString = "\${pcp.outbox.publish-fixed-delay-ms:5000}")
fun publishScheduledBatch() {
publishPending()
}
fun publishPending(): Int {
val batchSize = outboxProperties.publishBatchSize.coerceAtLeast(0)
if (batchSize == 0) {
return 0
}
return try {
transactionOperations.execute(
TransactionCallback {
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
batchSize = batchSize,
).distinctBy { it.id }
events.forEach { event ->
publishAndUpdateStatus(event)
}
events.size
},
) ?: 0
} catch (exception: Exception) {
log.error("Failed to process REQUEST_COMPLETED outbox event batch", exception)
0
}
}
private fun publishAndUpdateStatus(event: OutboxEventEntity) {
try {
kafkaTemplate
.send(
outboxProperties.requestCompletedTopic,
event.aggregateId.toString(),
event.payload.toString(),
)
.get()
event.status = OutboxEventStatus.PUBLISHED
event.publishedAt = LocalDateTime.now()
event.errorMessage = null
outboxEventRepository.save(event)
log.info(
"Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
event.id,
event.aggregateId,
outboxProperties.requestCompletedTopic,
)
} catch (exception: Exception) {
val publishException = exception.unwrapExecutionException()
event.status = OutboxEventStatus.FAILED
event.errorMessage = publishException.shortMessage()
outboxEventRepository.save(event)
log.warn(
"Failed to publish REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
event.id,
event.aggregateId,
outboxProperties.requestCompletedTopic,
publishException,
)
}
}
private fun Exception.unwrapExecutionException(): Throwable {
return if (this is ExecutionException && cause != null) {
cause!!
} else {
this
}
}
private fun Throwable.shortMessage(): String {
val message = message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName
return message.take(MAX_ERROR_MESSAGE_LENGTH)
}
companion object {
private const val MAX_ERROR_MESSAGE_LENGTH = 512
}
}
@@ -0,0 +1,8 @@
package org.nstart.dep265.requestservice.service
import java.util.UUID
class RequestAlreadyExistsException(
val requestId: UUID,
cause: Throwable? = null,
) : RuntimeException("Request already exists: $requestId", cause)
@@ -0,0 +1,68 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.databind.ObjectMapper
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.util.UUID
@Service
class RequestCompletedOutboxService(
private val outboxEventRepository: OutboxEventRepository,
private val objectMapper: ObjectMapper,
private val geometryService: GeometryService,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Transactional(propagation = Propagation.MANDATORY)
fun createRequestCompletedIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) {
val completedAt = request.completedAt ?: return
val payload = RequestCompletedPayload(
requestId = request.id,
status = RequestStatus.COMPLETED.name,
completedAt = completedAt,
coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea),
matchedAt = request.lastMatchedAt,
eventCreatedAt = eventCreatedAt,
)
val inserted = outboxEventRepository.insertIfAbsent(
id = UUID.randomUUID(),
eventType = REQUEST_COMPLETED_EVENT_TYPE,
aggregateType = REQUEST_AGGREGATE_TYPE,
aggregateId = request.id,
payload = objectMapper.writeValueAsString(payload),
status = OutboxEventStatus.NEW.name,
createdAt = eventCreatedAt,
)
if (inserted == 0) {
log.debug(
"REQUEST_COMPLETED outbox event already exists for requestId={}",
request.id,
)
}
}
private data class RequestCompletedPayload(
val requestId: UUID,
val status: String,
val completedAt: LocalDateTime,
val coveragePercent: Double,
val matchedAt: LocalDateTime?,
val eventCreatedAt: LocalDateTime,
)
companion object {
const val REQUEST_COMPLETED_EVENT_TYPE = "REQUEST_COMPLETED"
const val REQUEST_AGGREGATE_TYPE = "REQUEST"
}
}
@@ -0,0 +1,122 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class RequestGridProjectionService(
private val requestRepository: RequestRepository,
private val earthCellRepository: EarthCellRepository,
private val requestCellRepository: RequestCellRepository,
private val geometryService: GeometryService,
@Value("\${app.grid.enabled:true}")
private val gridEnabled: Boolean,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Transactional
fun rebuildProjection(requestId: UUID) {
if (!gridEnabled) {
return
}
val requestEntity = requestRepository.findById(requestId).orElse(null) ?: return
rebuildProjection(requestEntity)
}
@Transactional
fun rebuildProjection(requestEntity: RequestEntity) {
if (!gridEnabled) {
return
}
/** Старая проекция удаляется целиком, потому что rebuild должен быть идемпотентным. */
requestCellRepository.deleteByRequestId(requestEntity.id)
val projectionGeometry = geometryService.parsePolygonalGeometry(requestEntity.geometry)
/** Для пересечения с сеткой [0..360] геометрию нужно перевести в непрерывный диапазон. */
val normalizedProjectionGeometry = LongitudeWrapGeometry.normalizeToContinuous360(projectionGeometry)
/** Поиск по одному WKT недостаточен для заявок, пересекающих нулевой меридиан. */
val intersectingCells = LongitudeWrapGeometry.buildSearchVariants(normalizedProjectionGeometry)
.flatMap { projectionVariant ->
earthCellRepository.findIntersectingByContour(geometryService.toWkt(projectionVariant))
}
.distinctBy { earthCell -> earthCell.cellId ?: earthCell.cellNum }
val requestCellEntities = mutableListOf<RequestCellEntity>()
intersectingCells.forEach { earthCell ->
val cellGeometry = geometryService.parsePolygonalGeometry(earthCell.contour)
/** Ячейка выравнивается к полосе долгот заявки, чтобы точное пересечение было корректным. */
val alignedCellGeometry = LongitudeWrapGeometry.alignToReference(cellGeometry, normalizedProjectionGeometry)
/** Пересечение заявка ∩ ячейка может дать Polygon, MultiPolygon или пустую геометрию. */
val intersectionGeometry = geometryService.keepPolygonalGeometry(
normalizedProjectionGeometry.intersection(alignedCellGeometry)
)
when (intersectionGeometry) {
is Polygon -> addProjectionPiece(requestCellEntities, requestEntity, earthCell, cellGeometry, intersectionGeometry)
is MultiPolygon -> {
for (geometryIndex in 0 until intersectionGeometry.numGeometries) {
/** Отдельная часть MultiPolygon добавляется как самостоятельный request_cells fragment. */
val geometryPart = intersectionGeometry.getGeometryN(geometryIndex)
if (geometryPart is Polygon && !geometryPart.isEmpty) {
addProjectionPiece(requestCellEntities, requestEntity, earthCell, cellGeometry, geometryPart)
}
}
}
}
}
requestCellRepository.saveAll(requestCellEntities)
log.info("Grid projection rebuilt: requestId={}, fragments={}", requestEntity.id, requestCellEntities.size)
}
private fun addProjectionPiece(
requestCellEntities: MutableList<RequestCellEntity>,
requestEntity: RequestEntity,
earthCell: EarthCellEntity,
cellGeometry: Geometry,
intersectionPolygon: Polygon,
) {
if (intersectionPolygon.isEmpty || cellGeometry.area <= 0.0) {
return
}
/** Процент покрытия нужен trigger'у importance и compat DTO CellRequestDOT. */
val coveragePercent = (intersectionPolygon.area / cellGeometry.area) * 100.0
val normalizedForCellBand = LongitudeWrapGeometry.alignToReference(intersectionPolygon, cellGeometry)
val intersectionWkt = geometryService.toWkt(normalizedForCellBand)
val requestCellEntity = RequestCellEntity(
id = null,
requestId = requestEntity.id,
coveragePercent = coveragePercent,
importance = requestEntity.importance,
contour = intersectionWkt,
cell = earthCell,
)
requestCellEntities.add(requestCellEntity)
}
}
@@ -0,0 +1,55 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@Service
class RequestLifecycleService(
private val geometryService: GeometryService,
) {
fun refreshStatus(request: RequestEntity, now: LocalDateTime): RequestLifecycleRefreshResult {
if (request.status == RequestStatus.DELETED) {
return RequestLifecycleRefreshResult(statusChanged = false, completedNow = false)
}
val previousStatus: RequestStatus = request.status
val previousCompletedAt = request.completedAt
val coveredPercent: Double = geometryService.coveredPercent(request.geometryArea, request.remainingArea)
val requiredPercent = request.coverageRequiredPercent
when {
coveredPercent >= requiredPercent -> {
request.status = RequestStatus.COMPLETED
if (request.completedAt == null) {
request.completedAt = now
}
}
now.isBefore(request.beginDateTime) -> {
request.status = RequestStatus.ACCEPTED
request.completedAt = null
}
now.isAfter(request.endDateTime) -> {
request.status = RequestStatus.EXPIRED
request.completedAt = null
}
else -> {
request.status = RequestStatus.ACTIVE
request.completedAt = null
}
}
return RequestLifecycleRefreshResult(
statusChanged = previousStatus != request.status || previousCompletedAt != request.completedAt,
completedNow = previousStatus != RequestStatus.COMPLETED && request.status == RequestStatus.COMPLETED,
)
}
}
data class RequestLifecycleRefreshResult(
val statusChanged: Boolean,
val completedNow: Boolean,
)
@@ -0,0 +1,358 @@
package org.nstart.dep265.requestservice.service
import jakarta.persistence.EntityExistsException
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceException
import org.hibernate.exception.ConstraintViolationException
import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto
import org.nstart.dep265.requestservice.dto.CreateRequestResponseDto
import org.nstart.dep265.requestservice.dto.CoverageStateDto
import org.nstart.dep265.requestservice.dto.DeleteRequestResponseDto
import org.nstart.dep265.requestservice.dto.DeleteRequestStatusDto
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.OpticsParamsDto
import org.nstart.dep265.requestservice.dto.RequestListResponseDto
import org.nstart.dep265.requestservice.dto.RequestCellResponseDto
import org.nstart.dep265.requestservice.dto.RequestResponseDto
import org.nstart.dep265.requestservice.dto.RequestSummaryResponseDto
import org.nstart.dep265.requestservice.dto.RequestWithCellsResponseDto
import org.nstart.dep265.requestservice.dto.RequestStatusDto
import org.nstart.dep265.requestservice.dto.RsaParamsDto
import org.nstart.dep265.requestservice.dto.SurveyTypeDto
import org.nstart.dep265.requestservice.entity.OpticsResultType
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.entity.RsaPolarisation
import org.nstart.dep265.requestservice.entity.RsaResultType
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
class InvalidRequestListSortException(message: String) : IllegalArgumentException(message)
@Service
class RequestService(
private val requestRepository: RequestRepository,
private val requestOpticsParamsRepository: RequestOpticsParamsRepository,
private val requestRsaParamsRepository: RequestRsaParamsRepository,
private val requestCellRepository: RequestCellRepository,
private val geometryService: GeometryService,
private val requestGridProjectionService: RequestGridProjectionService,
private val entityManager: EntityManager,
) {
@Transactional
fun createRequest(request: CreateRequestRequestDto): CreateRequestResponseDto {
val requestId = request.id
if (requestRepository.existsById(requestId)) {
throw RequestAlreadyExistsException(requestId)
}
require(request.beginDateTime.isBefore(request.endDateTime)) {
"beginDateTime должен быть раньше endDateTime"
}
val now = LocalDateTime.now()
val normalizedGeometry = geometryService.parsePolygonalGeometry(request.geometry)
val normalizedGeometryWkt = geometryService.toWkt(normalizedGeometry)
val geometryArea = geometryService.area(normalizedGeometry)
val surveyType = request.surveyType()
val importance = requireNotNull(request.importance) { "importance обязателен" }
val requestEntity = RequestEntity(
id = requestId,
name = request.name,
geometry = normalizedGeometryWkt,
remainingGeometry = normalizedGeometryWkt,
geometryArea = geometryArea,
remainingArea = geometryArea,
coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT,
importance = importance,
beginDateTime = request.beginDateTime.toUtcLocalDateTime(),
endDateTime = request.endDateTime.toUtcLocalDateTime(),
kpp = request.kpp.toMutableList(),
highPriorityTransmit = request.highPriorityTransmit,
surveyType = surveyType,
status = RequestStatus.ACCEPTED,
createdAt = now,
updatedAt = now,
)
persistNewRequest(requestEntity)
replaceSurveyParams(requestEntity.id, request.optics, request.rsa)
requestGridProjectionService.rebuildProjection(requestEntity)
return CreateRequestResponseDto(
id = requestEntity.id,
name = requestEntity.name,
status = RequestStatusDto.ACCEPTED,
surveyType = requestEntity.surveyType.toDto(),
importance = requestEntity.importance,
message = "Заявка успешно создана",
)
}
@Transactional(readOnly = true)
fun listRequests(query: ListRequestsQueryDto): RequestListResponseDto {
val requests = requestRepository.findRequests(
includeDeleted = query.includeDeleted,
deletedStatus = RequestStatus.DELETED,
status = query.status?.toEntity(),
surveyType = query.surveyType?.toEntity(),
kpp = query.kpp,
highPriorityTransmit = query.highPriorityTransmit,
beginFrom = query.beginFrom?.toUtcLocalDateTime(),
beginTo = query.beginTo?.toUtcLocalDateTime(),
endFrom = query.endFrom?.toUtcLocalDateTime(),
endTo = query.endTo?.toUtcLocalDateTime(),
pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()),
)
return RequestListResponseDto(
items = requests.content.map { request -> request.toSummaryResponse() },
page = query.page,
size = query.size,
totalItems = requests.totalElements,
totalPages = requests.totalPages,
)
}
@Transactional(readOnly = true)
fun getRequestById(id: UUID): RequestResponseDto? {
val request = requestRepository.findById(id).orElse(null) ?: return null
if (request.status == RequestStatus.DELETED) {
return null
}
return request.toResponse()
}
@Transactional(readOnly = true)
fun getRequestWithCells(id: UUID): RequestWithCellsResponseDto? {
val request = requestRepository.findById(id).orElse(null) ?: return null
val cells = requestCellRepository.findWithCellByRequestId(id)
.map { requestCell -> requestCell.toRequestCellResponse() }
return RequestWithCellsResponseDto(
request = request.toResponse(),
cells = cells,
)
}
@Transactional
fun deleteRequest(id: UUID): DeleteRequestResponseDto? {
val request = requestRepository.findById(id).orElse(null) ?: return null
val deletedAt = request.deletedAt ?: LocalDateTime.now()
request.status = RequestStatus.DELETED
request.deletedAt = deletedAt
request.updatedAt = deletedAt
requestRepository.save(request)
requestCellRepository.deleteByRequestId(request.id)
return DeleteRequestResponseDto(
id = request.id,
status = DeleteRequestStatusDto.DELETED,
deletedAt = deletedAt.toOffsetDateTime(),
)
}
private fun persistNewRequest(requestEntity: RequestEntity) {
try {
entityManager.persist(requestEntity)
entityManager.flush()
} catch (exception: EntityExistsException) {
throw RequestAlreadyExistsException(requestEntity.id, exception)
} catch (exception: PersistenceException) {
if (exception.isDuplicateRequestId()) {
throw RequestAlreadyExistsException(requestEntity.id, exception)
}
throw exception
}
}
private fun replaceSurveyParams(requestId: UUID, optics: OpticsParamsDto?, rsa: RsaParamsDto?) {
if (optics == null) {
if (requestOpticsParamsRepository.existsById(requestId)) {
requestOpticsParamsRepository.deleteById(requestId)
}
} else {
requestOpticsParamsRepository.save(optics.toEntity(requestId))
}
if (rsa == null) {
if (requestRsaParamsRepository.existsById(requestId)) {
requestRsaParamsRepository.deleteById(requestId)
}
} else {
requestRsaParamsRepository.save(rsa.toEntity(requestId))
}
}
private fun RequestEntity.toResponse(): RequestResponseDto {
val optics = requestOpticsParamsRepository.findById(id).orElse(null)?.toDto()
val rsa = requestRsaParamsRepository.findById(id).orElse(null)?.toDto()
return RequestResponseDto(
id = id,
name = name,
status = status.toDto(),
surveyType = surveyType.toDto(),
geometry = geometry,
importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(),
endDateTime = endDateTime.toOffsetDateTime(),
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
optics = optics,
rsa = rsa,
coverage = coverage(),
createdAt = createdAt.toOffsetDateTime(),
updatedAt = updatedAt.toOffsetDateTime(),
deletedAt = deletedAt?.toOffsetDateTime(),
)
}
private fun RequestCellEntity.toRequestCellResponse(): RequestCellResponseDto {
return RequestCellResponseDto(
cellNum = requireNotNull(cell?.cellNum) { "request_cell must be linked to earth_cell" },
coveragePercent = coveragePercent ?: 0.0,
importance = importance,
contour = contour,
)
}
private fun RequestEntity.toSummaryResponse(): RequestSummaryResponseDto {
return RequestSummaryResponseDto(
id = id,
name = name,
status = status.toDto(),
surveyType = surveyType.toDto(),
importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(),
endDateTime = endDateTime.toOffsetDateTime(),
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
coveragePercent = coverage().currentPercent,
createdAt = createdAt.toOffsetDateTime(),
updatedAt = updatedAt.toOffsetDateTime(),
deletedAt = deletedAt?.toOffsetDateTime(),
)
}
private fun RequestEntity.coverage(): CoverageStateDto {
return CoverageStateDto(
requiredPercent = coverageRequiredPercent,
currentPercent = geometryService.coveredPercent(geometryArea, remainingArea),
)
}
private fun CreateRequestRequestDto.surveyType(): RequestSurveyType {
return when {
optics != null && rsa != null -> RequestSurveyType.COMBINED
optics != null -> RequestSurveyType.OPTICS
rsa != null -> RequestSurveyType.RSA
else -> throw IllegalArgumentException("Должен быть указан хотя бы один блок съемки: optics или rsa")
}
}
private fun OpticsParamsDto.toEntity(requestId: UUID): RequestOpticsParamsEntity {
return RequestOpticsParamsEntity(
requestId = requestId,
resultType = OpticsResultType.valueOf(resultType.name),
resolution = resolution,
sunAngleMin = sunAngleMin,
sunAngleMax = sunAngleMax,
clouds = clouds,
)
}
private fun RequestOpticsParamsEntity.toDto(): OpticsParamsDto {
return OpticsParamsDto(
resultType = org.nstart.dep265.requestservice.dto.OpticsResultTypeDto.valueOf(resultType.name),
resolution = resolution,
sunAngleMin = sunAngleMin,
sunAngleMax = sunAngleMax,
clouds = clouds,
)
}
private fun RsaParamsDto.toEntity(requestId: UUID): RequestRsaParamsEntity {
return RequestRsaParamsEntity(
requestId = requestId,
resultType = RsaResultType.valueOf(resultType.name),
interferometry = interferometry,
polarisation = RsaPolarisation.valueOf(polarisation.name),
resolution = resolution,
)
}
private fun RequestRsaParamsEntity.toDto(): RsaParamsDto {
return RsaParamsDto(
resultType = org.nstart.dep265.requestservice.dto.RsaResultTypeDto.valueOf(resultType.name),
interferometry = interferometry,
polarisation = org.nstart.dep265.requestservice.dto.PolarisationDto.valueOf(polarisation.name),
resolution = resolution,
)
}
private fun RequestStatus.toDto(): RequestStatusDto = RequestStatusDto.valueOf(name)
private fun RequestSurveyType.toDto(): SurveyTypeDto = SurveyTypeDto.valueOf(name)
private fun RequestStatusDto.toEntity(): RequestStatus = RequestStatus.valueOf(name)
private fun SurveyTypeDto.toEntity(): RequestSurveyType = RequestSurveyType.valueOf(name)
private fun String.toRequestSort(): Sort {
val parts = split(",").map { part -> part.trim() }.filter { part -> part.isNotEmpty() }
val field = parts.firstOrNull() ?: DEFAULT_SORT_FIELD
val direction = parts.getOrNull(1)?.let { value ->
when (value.lowercase()) {
"asc" -> Sort.Direction.ASC
"desc" -> Sort.Direction.DESC
else -> throw InvalidRequestListSortException("Unsupported sort direction: $value")
}
} ?: DEFAULT_SORT_DIRECTION
val property = ALLOWED_SORT_FIELDS[field]
?: throw InvalidRequestListSortException("Unsupported sort field: $field")
return Sort.by(direction, property)
}
private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()
private fun LocalDateTime.toOffsetDateTime(): OffsetDateTime = atOffset(ZoneOffset.UTC)
private fun Throwable.isDuplicateRequestId(): Boolean {
return generateSequence(this) { throwable -> throwable.cause }
.filterIsInstance<ConstraintViolationException>()
.any { exception ->
exception.sqlState == POSTGRES_UNIQUE_VIOLATION &&
(exception.constraintName == null || exception.constraintName == REQUESTS_PRIMARY_KEY)
}
}
private companion object {
const val DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0
const val POSTGRES_UNIQUE_VIOLATION = "23505"
const val REQUESTS_PRIMARY_KEY = "requests_pkey"
const val DEFAULT_SORT_FIELD = "createdAt"
val DEFAULT_SORT_DIRECTION: Sort.Direction = Sort.Direction.DESC
val ALLOWED_SORT_FIELDS: Map<String, String> = mapOf(
"createdAt" to "createdAt",
"updatedAt" to "updatedAt",
"beginDateTime" to "beginDateTime",
"endDateTime" to "endDateTime",
"status" to "status",
"surveyType" to "surveyType",
"importance" to "importance",
)
}
}
@@ -0,0 +1,36 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.nstart.dep265.requestservice.dto.RouteDto
import org.springframework.stereotype.Service
@Service
class RequestValidationService(
private val geometryService: GeometryService,
) {
private val maxAllowedRollAngle: Double = 90.0
fun validate(route: RouteDto) {
require(route.intervalBegin.isBefore(route.intervalEnd)) {
"Начало интервала маршрута должно быть раньше окончания"
}
require(route.rollAngle.min >= 0.0) {
"Минимальный крен маршрута не может быть отрицательным"
}
require(route.rollAngle.max <= maxAllowedRollAngle) {
"Максимальный крен маршрута должен быть в диапазоне от 0 до $maxAllowedRollAngle градусов"
}
require(route.rollAngle.min <= route.rollAngle.max) {
"Диапазон крена маршрута задан некорректно"
}
val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry)
require(routeGeometry is Polygon || routeGeometry is MultiPolygon) {
"Геометрия маршрута должна быть POLYGON или MULTIPOLYGON в формате WKT"
}
require(routeGeometry.area > 0.0) {
"Геометрия маршрута должна иметь положительную площадь"
}
}
}
@@ -0,0 +1,230 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Geometry
import org.nstart.dep265.requestservice.dto.RouteDto
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRouteMatchRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.util.UUID
import kotlin.math.max
import kotlin.math.min
@Service
class RouteMatchingService(
private val requestRepository: RequestRepository,
private val requestRouteMatchRepository: RequestRouteMatchRepository,
private val requestValidationService: RequestValidationService,
private val geometryService: GeometryService,
private val requestLifecycleService: RequestLifecycleService,
private val requestCompletedOutboxService: RequestCompletedOutboxService,
@param:Qualifier("routeMatchingTransactionOperations")
private val routeMatchingTransactionOperations: TransactionOperations,
) {
private val log = LoggerFactory.getLogger(this::class.java)
fun process(route: RouteDto) {
requestValidationService.validate(route)
val routeId = route.routeId
val routeRollRange = route.rollAngle
val matchedAt: LocalDateTime = LocalDateTime.now()
val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry)
val candidateRequestIds = requestRepository.findRequestIdsForRoute(
statuses = listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE),
routeBegin = route.intervalBegin,
routeEnd = route.intervalEnd,
)
var actualMatches = 0
candidateRequestIds.forEach { requestId ->
if (requestRouteMatchRepository.existsByRequestIdAndRouteId(requestId, routeId)) {
return@forEach
}
try {
val contributed = routeMatchingTransactionOperations.execute(
TransactionCallback {
applyRouteToRequest(
requestId = requestId,
routeId = routeId,
routeGeometry = routeGeometry,
matchedAt = matchedAt,
)
},
) ?: false
if (contributed) {
actualMatches += 1
}
} catch (exception: DataIntegrityViolationException) {
if (!exception.isDuplicateRouteMatchViolation()) {
throw exception
}
log.debug(
"Route match already exists after concurrent insert: requestId={}, routeId={}",
requestId,
routeId,
)
}
}
log.info(
"Route processed: routeId={}, candidatesMatched={}, actualMatches={}, roll={}",
routeId,
candidateRequestIds.size,
actualMatches,
routeRollRange,
)
}
private fun applyRouteToRequest(
requestId: UUID,
routeId: UUID,
routeGeometry: Geometry,
matchedAt: LocalDateTime,
): Boolean {
val request = requestRepository.findByIdForUpdate(requestId).orElse(null) ?: return false
if (request.status !in ACTIVE_ROUTE_MATCHING_STATUSES || request.deletedAt != null) {
return false
}
if (requestRouteMatchRepository.existsByRequestIdAndRouteId(request.id, routeId)) {
return false
}
val originalGeometry = geometryService.parsePolygonalGeometry(request.geometry)
val originalIntersection = geometryService.keepPolygonalGeometry(originalGeometry.intersection(routeGeometry))
val originalIntersectionArea = geometryService.area(originalIntersection)
if (!originalIntersection.hasPositiveArea(originalIntersectionArea)) {
return false
}
val remainingGeometry = geometryService.parsePolygonalGeometry(request.remainingGeometry)
val remainingIntersection = geometryService.keepPolygonalGeometry(remainingGeometry.intersection(routeGeometry))
val remainingIntersectionArea = geometryService.area(remainingIntersection)
if (!remainingIntersection.hasPositiveArea(remainingIntersectionArea)) {
saveRouteMatch(
request = request,
routeId = routeId,
originalIntersection = originalIntersection,
originalIntersectionArea = originalIntersectionArea,
appliedIntersection = null,
appliedIntersectionArea = 0.0,
coverageDeltaPercent = 0.0,
contributesToCoverage = false,
matchedAt = matchedAt,
)
return false
}
val updatedRemainingGeometry = geometryService.keepPolygonalGeometry(
remainingGeometry.difference(remainingIntersection),
)
val updatedRemainingArea = max(geometryService.area(updatedRemainingGeometry), 0.0)
val coverageDeltaPercent = coverageDeltaPercent(
originalArea = request.geometryArea,
appliedIntersectionArea = remainingIntersectionArea,
)
saveRouteMatch(
request = request,
routeId = routeId,
originalIntersection = originalIntersection,
originalIntersectionArea = originalIntersectionArea,
appliedIntersection = remainingIntersection,
appliedIntersectionArea = remainingIntersectionArea,
coverageDeltaPercent = coverageDeltaPercent,
contributesToCoverage = true,
matchedAt = matchedAt,
)
request.remainingGeometry = geometryService.toWkt(updatedRemainingGeometry)
request.remainingArea = updatedRemainingArea
request.matchCount = request.matchCount + 1
request.lastMatchedAt = matchedAt
request.updatedAt = matchedAt
val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt)
requestRepository.save(request)
if (statusRefresh.completedNow) {
requestCompletedOutboxService.createRequestCompletedIfAbsent(request, matchedAt)
}
return true
}
private fun saveRouteMatch(
request: RequestEntity,
routeId: UUID,
originalIntersection: Geometry,
originalIntersectionArea: Double,
appliedIntersection: Geometry?,
appliedIntersectionArea: Double,
coverageDeltaPercent: Double,
contributesToCoverage: Boolean,
matchedAt: LocalDateTime,
) {
val requestRouteMatch = RequestRouteMatchEntity(
requestId = request.id,
routeId = routeId,
originalIntersectionGeometry = geometryService.toWkt(originalIntersection),
originalIntersectionArea = originalIntersectionArea,
appliedIntersectionGeometry = appliedIntersection?.let { geometryService.toWkt(it) },
appliedIntersectionArea = appliedIntersectionArea,
coverageDeltaPercent = coverageDeltaPercent,
contributesToCoverage = contributesToCoverage,
matchedAt = matchedAt,
)
requestRouteMatchRepository.saveAndFlush(requestRouteMatch)
}
private fun coverageDeltaPercent(originalArea: Double, appliedIntersectionArea: Double): Double {
if (originalArea <= 0.0) {
return 0.0
}
val percent = (appliedIntersectionArea / originalArea) * 100.0
return min(max(percent, 0.0), 100.0)
}
private fun Geometry.hasPositiveArea(area: Double): Boolean = !isEmpty && area > 0.0
private fun DataIntegrityViolationException.isDuplicateRouteMatchViolation(): Boolean {
if (this is DuplicateKeyException) {
return true
}
val message = listOfNotNull(message, mostSpecificCause.message)
.joinToString(separator = " ")
.lowercase()
return message.contains("uk_request_route_match") ||
message.contains("request_id, route_id") ||
(message.contains("duplicate") && message.contains("unique"))
}
companion object {
private val ACTIVE_ROUTE_MATCHING_STATUSES = setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE)
}
}
@@ -0,0 +1,19 @@
spring:
application:
name: pcp-request-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
pcp:
outbox:
request-completed-topic: pcp.request.completed.v1
publish-batch-size: 50
publish-fixed-delay-ms: 5000
@@ -0,0 +1,210 @@
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS requests (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
geometry TEXT NOT NULL,
remaining_geometry TEXT NOT NULL,
geometry_area DOUBLE PRECISION NOT NULL,
remaining_area DOUBLE PRECISION NOT NULL,
coverage_required_percent DOUBLE PRECISION NOT NULL DEFAULT 100.0,
importance DOUBLE PRECISION NOT NULL,
begin_date_time TIMESTAMP NOT NULL,
end_date_time TIMESTAMP NOT NULL,
high_priority_transmit BOOLEAN NOT NULL DEFAULT false,
survey_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
match_count INTEGER NOT NULL DEFAULT 0,
last_matched_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT chk_requests_status
CHECK (status IN ('ACCEPTED', 'ACTIVE', 'COMPLETED', 'EXPIRED', 'DELETED')),
CONSTRAINT chk_requests_survey_type
CHECK (survey_type IN ('OPTICS', 'RSA', 'COMBINED')),
CONSTRAINT chk_requests_time_window
CHECK (begin_date_time < end_date_time),
CONSTRAINT chk_requests_importance_non_negative
CHECK (importance >= 0),
CONSTRAINT chk_requests_name_not_blank
CHECK (length(trim(name)) > 0),
CONSTRAINT chk_requests_soft_delete
CHECK ((status = 'DELETED' AND deleted_at IS NOT NULL) OR (status <> 'DELETED'))
);
CREATE TABLE IF NOT EXISTS request_kpp (
request_id UUID NOT NULL,
kpp INTEGER NOT NULL,
CONSTRAINT fk_request_kpp_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS request_optics_params (
request_id UUID PRIMARY KEY,
result_type VARCHAR(32) NOT NULL,
resolution DOUBLE PRECISION NOT NULL,
sun_angle_min DOUBLE PRECISION NOT NULL DEFAULT 10.0,
sun_angle_max DOUBLE PRECISION NOT NULL DEFAULT 90.0,
clouds DOUBLE PRECISION NOT NULL DEFAULT 100.0,
CONSTRAINT fk_request_optics_params_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT chk_request_optics_result_type
CHECK (result_type IN ('PANCHROMATIC', 'MULTISPECTRAL', 'PANSHARPENING'))
);
CREATE TABLE IF NOT EXISTS request_rsa_params (
request_id UUID PRIMARY KEY,
result_type VARCHAR(32) NOT NULL,
interferometry BOOLEAN NOT NULL DEFAULT false,
polarisation VARCHAR(8) NOT NULL,
resolution DOUBLE PRECISION NOT NULL,
CONSTRAINT fk_request_rsa_params_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT chk_request_rsa_result_type
CHECK (result_type IN ('SPOTLIGHT', 'STRIPMAP', 'SCANSAR')),
CONSTRAINT chk_request_rsa_polarisation
CHECK (polarisation IN ('HH', 'VV'))
);
CREATE TABLE IF NOT EXISTS request_route_matches (
id BIGSERIAL PRIMARY KEY,
request_id UUID NOT NULL,
route_id UUID NOT NULL,
original_intersection_geometry TEXT NOT NULL,
original_intersection_area DOUBLE PRECISION NOT NULL,
applied_intersection_geometry TEXT NULL,
applied_intersection_area DOUBLE PRECISION NOT NULL DEFAULT 0.0,
coverage_delta_percent DOUBLE PRECISION NOT NULL DEFAULT 0.0,
contributes_to_coverage BOOLEAN NOT NULL DEFAULT false,
matched_at TIMESTAMP NOT NULL,
CONSTRAINT fk_request_route_matches_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT uk_request_route_match UNIQUE (request_id, route_id)
);
CREATE TABLE IF NOT EXISTS outbox_events (
id UUID PRIMARY KEY,
event_type VARCHAR(128) NOT NULL,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id UUID NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL,
error_message TEXT NULL,
CONSTRAINT chk_outbox_events_status
CHECK (status IN ('NEW', 'PUBLISHED', 'FAILED')),
CONSTRAINT uk_outbox_events_event_type_aggregate_id
UNIQUE (event_type, aggregate_id)
);
CREATE TABLE IF NOT EXISTS earth_cells (
cell_id BIGSERIAL PRIMARY KEY,
cell_num BIGINT NOT NULL,
longitude DOUBLE PRECISION,
latitude DOUBLE PRECISION,
importance DOUBLE PRECISION NOT NULL DEFAULT 0.0,
contour_wkt TEXT NOT NULL,
contour_geom geometry(Polygon, 4326)
);
CREATE TABLE IF NOT EXISTS request_cells (
id BIGSERIAL PRIMARY KEY,
request_id UUID NOT NULL,
cell_id BIGINT NOT NULL,
coverage_percent DOUBLE PRECISION,
importance DOUBLE PRECISION NOT NULL,
contour_wkt TEXT NOT NULL,
contour_geom geometry(Polygon, 4326),
CONSTRAINT fk_request_cells_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT fk_request_cells_cell
FOREIGN KEY (cell_id)
REFERENCES earth_cells(cell_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS earth_grid_settings (
settings_id BIGINT PRIMARY KEY,
calculation_step DOUBLE PRECISION NOT NULL
);
INSERT INTO earth_grid_settings(settings_id, calculation_step)
VALUES (1, 2.0)
ON CONFLICT (settings_id) DO NOTHING;
CREATE OR REPLACE FUNCTION fill_earth_cell_geometry()
RETURNS TRIGGER AS $$
BEGIN
NEW.contour_geom := ST_GeomFromText(NEW.contour_wkt, 4326)::geometry(Polygon, 4326);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION fill_request_cell_geometry()
RETURNS TRIGGER AS $$
BEGIN
NEW.contour_geom := ST_GeomFromText(NEW.contour_wkt, 4326)::geometry(Polygon, 4326);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION update_earth_cell_importance()
RETURNS TRIGGER AS $$
BEGIN
UPDATE earth_cells earth_cell
SET importance = (
SELECT COALESCE(SUM(request_cell.importance * (COALESCE(request_cell.coverage_percent, 0.0) / 100.0)), 0.0)
FROM request_cells request_cell
WHERE request_cell.cell_id = COALESCE(NEW.cell_id, OLD.cell_id)
)
WHERE earth_cell.cell_id = COALESCE(NEW.cell_id, OLD.cell_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_fill_earth_cell_geometry ON earth_cells;
CREATE TRIGGER trg_fill_earth_cell_geometry
BEFORE INSERT OR UPDATE OF contour_wkt ON earth_cells
FOR EACH ROW EXECUTE FUNCTION fill_earth_cell_geometry();
DROP TRIGGER IF EXISTS trg_fill_request_cell_geometry ON request_cells;
CREATE TRIGGER trg_fill_request_cell_geometry
BEFORE INSERT OR UPDATE OF contour_wkt ON request_cells
FOR EACH ROW EXECUTE FUNCTION fill_request_cell_geometry();
DROP TRIGGER IF EXISTS request_cells_importance_trigger ON request_cells;
CREATE TRIGGER request_cells_importance_trigger
AFTER INSERT OR UPDATE OR DELETE ON request_cells
FOR EACH ROW EXECUTE FUNCTION update_earth_cell_importance();
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_survey_type ON requests(survey_type);
CREATE INDEX IF NOT EXISTS idx_requests_begin_date_time ON requests(begin_date_time);
CREATE INDEX IF NOT EXISTS idx_requests_end_date_time ON requests(end_date_time);
CREATE INDEX IF NOT EXISTS idx_requests_deleted_at ON requests(deleted_at);
CREATE INDEX IF NOT EXISTS idx_requests_completed_at ON requests(completed_at);
CREATE INDEX IF NOT EXISTS idx_requests_cleanup_status_end ON requests(status, end_date_time);
CREATE INDEX IF NOT EXISTS idx_request_kpp_request_id ON request_kpp(request_id);
CREATE INDEX IF NOT EXISTS idx_request_kpp_kpp ON request_kpp(kpp);
CREATE INDEX IF NOT EXISTS idx_request_route_matches_route_id ON request_route_matches(route_id);
CREATE INDEX IF NOT EXISTS idx_outbox_events_status_created_at ON outbox_events(status, created_at);
CREATE INDEX IF NOT EXISTS idx_earth_cells_cell_num ON earth_cells(cell_num);
CREATE INDEX IF NOT EXISTS idx_earth_cells_contour_geom ON earth_cells USING GIST (contour_geom);
CREATE INDEX IF NOT EXISTS idx_request_cells_request_id ON request_cells(request_id);
CREATE INDEX IF NOT EXISTS idx_request_cells_cell_id ON request_cells(cell_id);
CREATE INDEX IF NOT EXISTS idx_request_cells_contour_geom ON request_cells USING GIST (contour_geom);