From 17fb44573dd3e7504e052193993f00e01604d9d2 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:34:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20&=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=ED=86=B5=ED=95=A9=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/order/business/OrderService.kt | 130 ++++++++++++++++++ .../sangdol/roomescape/order/docs/OrderAPI.kt | 22 +++ .../roomescape/order/web/OrderController.kt | 25 ++++ .../payment/business/PaymentService.kt | 5 +- 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt new file mode 100644 index 00000000..1662d322 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt @@ -0,0 +1,130 @@ +package com.sangdol.roomescape.order.business + +import com.sangdol.common.persistence.IDGenerator +import com.sangdol.common.persistence.TransactionExecutionUtil +import com.sangdol.common.types.exception.ErrorCode +import com.sangdol.common.types.exception.RoomescapeException +import com.sangdol.roomescape.order.exception.OrderErrorCode +import com.sangdol.roomescape.order.exception.OrderException +import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult +import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity +import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository +import com.sangdol.roomescape.payment.business.PaymentService +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.exception.PaymentException +import com.sangdol.roomescape.reservation.business.ReservationService +import com.sangdol.roomescape.reservation.dto.ReservationStateResponse +import com.sangdol.roomescape.schedule.business.ScheduleService +import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class OrderService( + private val idGenerator: IDGenerator, + private val reservationService: ReservationService, + private val scheduleService: ScheduleService, + private val paymentService: PaymentService, + private val transactionExecutionUtil: TransactionExecutionUtil, + private val orderValidator: OrderValidator, + private val paymentAttemptRepository: PaymentAttemptRepository, + private val orderPostProcessorService: OrderPostProcessorService +) { + + fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) { + val trial = paymentAttemptRepository.countByReservationId(reservationId) + val paymentKey = paymentConfirmRequest.paymentKey + + log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } + + try { + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + validateAndMarkInProgress(reservationId) + } + + val paymentClientResponse: PaymentGatewayResponse = + requestConfirmPayment(reservationId, paymentConfirmRequest) + + orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse) + } catch (e: Exception) { + val errorCode: ErrorCode = if (e is RoomescapeException) { + e.errorCode + } else { + OrderErrorCode.BOOKING_UNEXPECTED_ERROR + } + + throw OrderException(errorCode, e.message ?: errorCode.message, trial) + } + } + + private fun validateAndMarkInProgress(reservationId: Long) { + log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" } + val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId) + val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId) + + try { + orderValidator.validateCanConfirm(reservation, schedule) + log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" } + } catch (e: OrderException) { + val errorCode = OrderErrorCode.NOT_CONFIRMABLE + throw OrderException(errorCode, e.message) + } + + reservationService.markInProgress(reservationId) + } + + private fun requestConfirmPayment( + reservationId: Long, + paymentConfirmRequest: PaymentConfirmRequest + ): PaymentGatewayResponse { + log.info { "[requestConfirmPayment] 결제 및 이력 저장 시작: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" } + val paymentResponse: PaymentGatewayResponse + var attempt: PaymentAttemptEntity? = null + + try { + paymentResponse = paymentService.requestConfirm(paymentConfirmRequest) + + attempt = PaymentAttemptEntity( + id = idGenerator.create(), + reservationId = reservationId, + result = AttemptResult.SUCCESS, + ) + } catch (e: Exception) { + val errorCode: String = if (e is PaymentException) { + log.info { "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" } + e.errorCode.name + } else { + log.warn { + "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" + } + OrderErrorCode.BOOKING_UNEXPECTED_ERROR.name + } + + attempt = PaymentAttemptEntity( + id = idGenerator.create(), + reservationId = reservationId, + result = AttemptResult.FAILED, + errorCode = errorCode, + message = e.message + ) + + throw e + } finally { + val savedAttempt: PaymentAttemptEntity? = attempt?.let { + log.info { "[requestPayment] 결제 요청 이력 저장 시작: id=${it.id}, reservationId=${it.reservationId}, result=${it.result}, errorCode=${it.errorCode}, message=${it.message}" } + paymentAttemptRepository.save(it) + } + savedAttempt?.also { + log.info { "[requestPayment] 결제 요청 이력 저장 완료: id=${savedAttempt.id}" } + } ?: run { + log.info { "[requestPayment] 결제 요청 이력 저장 실패: reservationId=${reservationId}" } + } + } + + return paymentResponse + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt new file mode 100644 index 00000000..abb5932b --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt @@ -0,0 +1,22 @@ +package com.sangdol.roomescape.order.docs + +import com.sangdol.common.types.web.CommonApiResponse +import com.sangdol.roomescape.auth.web.support.UserOnly +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody + +interface OrderAPI { + + @UserOnly + @Operation(summary = "결제 및 예약 완료 처리") + @ApiResponses(ApiResponse(responseCode = "200")) + fun confirm( + @PathVariable("reservationId") reservationId: Long, + @RequestBody request: PaymentConfirmRequest + ): ResponseEntity> +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt new file mode 100644 index 00000000..cfd0f572 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt @@ -0,0 +1,25 @@ +package com.sangdol.roomescape.order.web + +import com.sangdol.common.types.web.CommonApiResponse +import com.sangdol.roomescape.order.business.OrderService +import com.sangdol.roomescape.order.docs.OrderAPI +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/orders") +class OrderController( + private val orderService: OrderService +) : OrderAPI { + + @PostMapping("/{reservationId}/confirm") + override fun confirm( + @PathVariable("reservationId") reservationId: Long, + @RequestBody request: PaymentConfirmRequest + ): ResponseEntity> { + orderService.confirm(reservationId, request) + + return ResponseEntity.ok(CommonApiResponse()) + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index f2f0010d..7985b20d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -31,8 +31,11 @@ class PaymentService( private val transactionExecutionUtil: TransactionExecutionUtil, ) { fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse { + log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" } try { - return paymentClient.confirm(request.paymentKey, request.orderId, request.amount) + return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also { + log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" } + } } catch (e: ExternalPaymentException) { val errorCode = if (e.httpStatusCode in 400..<500) { PaymentErrorCode.PAYMENT_CLIENT_ERROR