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.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
@ -40,22 +39,13 @@ class OrderValidator(
|
|||||||
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||||
}
|
}
|
||||||
ReservationStatus.EXPIRED -> {
|
ReservationStatus.EXPIRED -> {
|
||||||
log.info { "[validateCanConfirm] 만료된 예약 예약: id=${reservation.id}" }
|
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
|
||||||
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||||
}
|
}
|
||||||
ReservationStatus.CANCELED -> {
|
ReservationStatus.CANCELED -> {
|
||||||
log.info { "[validateCanConfirm] 취소된 예약 예약: id=${reservation.id}" }
|
log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" }
|
||||||
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
|
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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,10 @@ enum class OrderErrorCode(
|
|||||||
override val message: String
|
override val message: String
|
||||||
) : ErrorCode {
|
) : ErrorCode {
|
||||||
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
||||||
BOOKING_PAYMENT_TIMEOUT(HttpStatus.CONFLICT, "B001", "결제 가능 시간을 초과했어요. 처음부터 다시 시도해주세요."),
|
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
||||||
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B002", "이미 완료된 예약이에요."),
|
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||||
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B003", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||||
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B004", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
||||||
PAST_SCHEDULE(HttpStatus.CONFLICT, "B005", "지난 일정은 예약할 수 없어요."),
|
|
||||||
|
|
||||||
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||||
;
|
;
|
||||||
|
|||||||
@ -38,10 +38,14 @@ class IncompletedReservationScheduler(
|
|||||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||||
@Transactional
|
@Transactional
|
||||||
fun processExpiredReservation() {
|
fun processExpiredReservation() {
|
||||||
log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " }
|
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
|
||||||
|
|
||||||
reservationRepository.expirePendingReservations(Instant.now()).also {
|
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
|
||||||
log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" }
|
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")
|
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
|
||||||
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
|
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
|
@Modifying
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@ -29,8 +43,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
s.status = 'AVAILABLE',
|
s.status = 'AVAILABLE',
|
||||||
s.hold_expired_at = NULL
|
s.hold_expired_at = NULL
|
||||||
WHERE
|
WHERE
|
||||||
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
r.id IN :reservationIds
|
||||||
""", nativeQuery = true
|
""", 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,
|
paymentKey: String,
|
||||||
amount: Int,
|
amount: Int,
|
||||||
method: PaymentMethod,
|
method: PaymentMethod,
|
||||||
cardDetail: CardDetailResponse?,
|
cardDetail: CardDetailResponse? = null,
|
||||||
easyPayDetail: EasyPayDetailResponse?,
|
easyPayDetail: EasyPayDetailResponse? = null,
|
||||||
transferDetail: TransferDetailResponse?,
|
transferDetail: TransferDetailResponse? = null,
|
||||||
orderId: String = randomString(25),
|
orderId: String = randomString(25),
|
||||||
) = PaymentGatewayResponse(
|
) = PaymentGatewayResponse(
|
||||||
paymentKey = paymentKey,
|
paymentKey = paymentKey,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user