generated from pricelees/issue-pr-template
[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 #57
@ -11,7 +11,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
@ -40,22 +39,13 @@ class OrderValidator(
|
||||
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||
}
|
||||
ReservationStatus.EXPIRED -> {
|
||||
log.info { "[validateCanConfirm] 만료된 예약 예약: id=${reservation.id}" }
|
||||
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
|
||||
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||
}
|
||||
ReservationStatus.CANCELED -> {
|
||||
log.info { "[validateCanConfirm] 취소된 예약 예약: id=${reservation.id}" }
|
||||
log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" }
|
||||
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
|
||||
}
|
||||
ReservationStatus.PENDING -> {
|
||||
val pendingExpiredAt = reservation.createdAt.plusSeconds(5 * 60)
|
||||
val now = Instant.now()
|
||||
|
||||
if (now.isAfter(pendingExpiredAt)) {
|
||||
log.info { "[validateCanConfirm] Pending 예약 시간 내 미결제로 인한 실패: id=${reservation.id}, expiredAt=${pendingExpiredAt}, now=${now}" }
|
||||
throw OrderException(OrderErrorCode.BOOKING_PAYMENT_TIMEOUT)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,11 +9,10 @@ enum class OrderErrorCode(
|
||||
override val message: String
|
||||
) : ErrorCode {
|
||||
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
||||
BOOKING_PAYMENT_TIMEOUT(HttpStatus.CONFLICT, "B001", "결제 가능 시간을 초과했어요. 처음부터 다시 시도해주세요."),
|
||||
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B002", "이미 완료된 예약이에요."),
|
||||
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B003", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B004", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||
PAST_SCHEDULE(HttpStatus.CONFLICT, "B005", "지난 일정은 예약할 수 없어요."),
|
||||
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
||||
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
||||
|
||||
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||
;
|
||||
|
||||
@ -38,10 +38,14 @@ class IncompletedReservationScheduler(
|
||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||
@Transactional
|
||||
fun processExpiredReservation() {
|
||||
log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " }
|
||||
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
|
||||
|
||||
reservationRepository.expirePendingReservations(Instant.now()).also {
|
||||
log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" }
|
||||
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
|
||||
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
|
||||
}
|
||||
|
||||
reservationRepository.expirePendingReservations(Instant.now(), targets).also {
|
||||
log.info { "[processExpiredReservation] ${it}개의 예약 및 일정 처리 완료" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,20 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
||||
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
|
||||
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
|
||||
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
r.id
|
||||
FROM
|
||||
reservation r
|
||||
JOIN
|
||||
schedule s ON r.schedule_id = s.id AND s.status = 'HOLD'
|
||||
WHERE
|
||||
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""", nativeQuery = true)
|
||||
fun findAllExpiredReservation(): List<Long>
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
"""
|
||||
@ -29,8 +43,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
||||
s.status = 'AVAILABLE',
|
||||
s.hold_expired_at = NULL
|
||||
WHERE
|
||||
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
||||
r.id IN :reservationIds
|
||||
""", nativeQuery = true
|
||||
)
|
||||
fun expirePendingReservations(@Param("now") now: Instant): Int
|
||||
fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int
|
||||
}
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
package com.sangdol.roomescape.order
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import com.sangdol.roomescape.order.business.OrderService
|
||||
import com.sangdol.roomescape.order.exception.OrderException
|
||||
import com.sangdol.roomescape.payment.business.PaymentService
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||
import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler
|
||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
||||
import com.sangdol.roomescape.supports.PaymentFixture
|
||||
import com.sangdol.roomescape.supports.ReservationFixture
|
||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||
import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
|
||||
class OrderConcurrencyTest(
|
||||
@MockkBean(relaxed = true) private val paymentService: PaymentService,
|
||||
private val orderService: OrderService,
|
||||
private val transactionManager: PlatformTransactionManager,
|
||||
private val incompletedReservationScheduler: IncompletedReservationScheduler,
|
||||
private val jdbcTemplate: JdbcTemplate,
|
||||
private val scheduleRepository: ScheduleRepository,
|
||||
private val reservationRepository: ReservationRepository
|
||||
) : FunSpecSpringbootTest() {
|
||||
|
||||
init {
|
||||
val paymentConfirmRequest = PaymentFixture.confirmRequest
|
||||
val paymentGatewayResponse = PaymentFixture.confirmResponse(
|
||||
paymentConfirmRequest.paymentKey,
|
||||
paymentConfirmRequest.amount,
|
||||
PaymentMethod.CARD
|
||||
)
|
||||
|
||||
lateinit var user: UserEntity
|
||||
lateinit var schedule: ScheduleEntity
|
||||
lateinit var reservation: ReservationEntity
|
||||
|
||||
beforeTest {
|
||||
user = testAuthUtil.defaultUserLogin().first
|
||||
schedule = dummyInitializer.createSchedule(status = ScheduleStatus.HOLD, isHoldExpired = true)
|
||||
reservation = dummyInitializer.createPendingReservation(
|
||||
user = user,
|
||||
reservationRequest = ReservationFixture.pendingCreateRequest.copy(scheduleId = schedule.id)
|
||||
).also {
|
||||
val reservationId = it.id
|
||||
TransactionTemplate(transactionManager).execute {
|
||||
val sql =
|
||||
"UPDATE reservation SET created_at = DATE_SUB(NOW(), INTERVAL 6 MINUTE) WHERE id = $reservationId"
|
||||
jdbcTemplate.execute(sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
|
||||
every {
|
||||
paymentService.requestConfirm(paymentConfirmRequest)
|
||||
} returns paymentGatewayResponse
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
async {
|
||||
orderService.confirm(reservation.id, paymentConfirmRequest)
|
||||
}
|
||||
|
||||
delay(10)
|
||||
|
||||
async {
|
||||
TransactionTemplate(transactionManager).execute {
|
||||
incompletedReservationScheduler.processExpiredReservation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
||||
this.status shouldBe ReservationStatus.CONFIRMED
|
||||
}
|
||||
|
||||
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
||||
this.status shouldBe ScheduleStatus.RESERVED
|
||||
this.holdExpiredAt shouldBe null
|
||||
}
|
||||
}
|
||||
|
||||
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
|
||||
every {
|
||||
paymentService.requestConfirm(paymentConfirmRequest)
|
||||
} returns paymentGatewayResponse
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
async {
|
||||
TransactionTemplate(transactionManager).execute {
|
||||
incompletedReservationScheduler.processExpiredReservation()
|
||||
}
|
||||
}
|
||||
|
||||
async {
|
||||
assertThrows<OrderException> {
|
||||
orderService.confirm(reservation.id, paymentConfirmRequest)
|
||||
}.also {
|
||||
it.trial shouldBe 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
||||
this.status shouldBe ReservationStatus.EXPIRED
|
||||
}
|
||||
|
||||
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
||||
this.status shouldBe ScheduleStatus.AVAILABLE
|
||||
this.holdExpiredAt shouldBe null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -295,9 +295,9 @@ object PaymentFixture {
|
||||
paymentKey: String,
|
||||
amount: Int,
|
||||
method: PaymentMethod,
|
||||
cardDetail: CardDetailResponse?,
|
||||
easyPayDetail: EasyPayDetailResponse?,
|
||||
transferDetail: TransferDetailResponse?,
|
||||
cardDetail: CardDetailResponse? = null,
|
||||
easyPayDetail: EasyPayDetailResponse? = null,
|
||||
transferDetail: TransferDetailResponse? = null,
|
||||
orderId: String = randomString(25),
|
||||
) = PaymentGatewayResponse(
|
||||
paymentKey = paymentKey,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user