generated from pricelees/issue-pr-template
[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 #57
@ -33,13 +33,6 @@ class ReservationValidator {
|
|||||||
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
|
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
|
||||||
}
|
}
|
||||||
|
|
||||||
val scheduleHoldExpiredAt = schedule.holdExpiredAt
|
|
||||||
val nowInstant = Instant.now()
|
|
||||||
if (scheduleHoldExpiredAt != null && scheduleHoldExpiredAt.isBefore(nowInstant)) {
|
|
||||||
log.info { "[validateCanCreate] 해당 일정의 HOLD 만료 시간 초과로 인한 실패: expiredAt=${scheduleHoldExpiredAt.toKoreaDateTime()}(KST), now=${nowInstant.toKoreaDateTime()}(KST)" }
|
|
||||||
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
|
|
||||||
}
|
|
||||||
|
|
||||||
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
||||||
val nowDateTime = KoreaDateTime.now()
|
val nowDateTime = KoreaDateTime.now()
|
||||||
if (scheduleDateTime.isBefore(nowDateTime)) {
|
if (scheduleDateTime.isBefore(nowDateTime)) {
|
||||||
|
|||||||
@ -24,10 +24,14 @@ class IncompletedReservationScheduler(
|
|||||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||||
@Transactional
|
@Transactional
|
||||||
fun processExpiredHoldSchedule() {
|
fun processExpiredHoldSchedule() {
|
||||||
log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
||||||
|
|
||||||
scheduleRepository.releaseExpiredHolds(Instant.now()).also {
|
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
|
||||||
log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" }
|
log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRepository.releaseHeldSchedules(targets).also {
|
||||||
|
log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -126,6 +126,26 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
|
expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
|
||||||
): Int
|
): Int
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id
|
||||||
|
FROM
|
||||||
|
schedule s
|
||||||
|
WHERE
|
||||||
|
s.status = 'HOLD'
|
||||||
|
AND s.hold_expired_at <= :now
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM reservation r
|
||||||
|
WHERE r.schedule_id = s.id AND r.status = 'PENDING'
|
||||||
|
)
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
""", nativeQuery = true
|
||||||
|
)
|
||||||
|
fun findAllExpiredHeldSchedules(@Param("now") now: Instant): List<Long>
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
|
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
|
||||||
s.holdExpiredAt = NULL
|
s.holdExpiredAt = NULL
|
||||||
WHERE
|
WHERE
|
||||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD
|
s._id IN :scheduleIds
|
||||||
AND s.holdExpiredAt <= :now
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM ReservationEntity r
|
|
||||||
WHERE r.scheduleId = s._id AND r.status = com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus.PENDING
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun releaseExpiredHolds(@Param("now") now: Instant): Int
|
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,21 +100,6 @@ class ReservationApiTest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("해당 일정의 hold_expired_at 시간이 지났다면 실패한다.") {
|
|
||||||
val schedule: ScheduleEntity = dummyInitializer.createSchedule(
|
|
||||||
status = ScheduleStatus.HOLD,
|
|
||||||
isHoldExpired = true
|
|
||||||
)
|
|
||||||
|
|
||||||
runExceptionTest(
|
|
||||||
token = testAuthUtil.defaultUserLogin().second,
|
|
||||||
method = HttpMethod.POST,
|
|
||||||
endpoint = endpoint,
|
|
||||||
requestBody = commonRequest.copy(scheduleId = schedule.id),
|
|
||||||
expectedErrorCode = ReservationErrorCode.EXPIRED_HELD_SCHEDULE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("현재 시간이 일정의 시작 시간 이후이면 실패한다.") {
|
test("현재 시간이 일정의 시작 시간 이후이면 실패한다.") {
|
||||||
val schedule: ScheduleEntity = dummyInitializer.createSchedule(
|
val schedule: ScheduleEntity = dummyInitializer.createSchedule(
|
||||||
status = ScheduleStatus.HOLD,
|
status = ScheduleStatus.HOLD,
|
||||||
|
|||||||
@ -3,12 +3,11 @@ package com.sangdol.roomescape.reservation
|
|||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.reservation.business.ReservationService
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler
|
import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationException
|
import com.sangdol.roomescape.reservation.exception.ReservationException
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
|
||||||
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
@ -19,7 +18,9 @@ import io.kotest.matchers.nulls.shouldNotBeNull
|
|||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.transaction.PlatformTransactionManager
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
import org.springframework.transaction.support.TransactionTemplate
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
@ -33,23 +34,68 @@ class ReservationConcurrencyTest(
|
|||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
context("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 로직의 완료 이후에 처리된다.") {
|
|
||||||
lateinit var user: UserEntity
|
lateinit var user: UserEntity
|
||||||
|
lateinit var schedule: ScheduleEntity
|
||||||
|
|
||||||
beforeTest {
|
beforeTest {
|
||||||
user = testAuthUtil.defaultUserLogin().first
|
user = testAuthUtil.defaultUserLogin().first
|
||||||
}
|
schedule = dummyInitializer.createSchedule(
|
||||||
|
|
||||||
test("holdExpiredAt이 지난 일정인 경우, Pending 예약 생성 중 예외가 발생하고 이후 배치가 재활성화 처리한다.") {
|
|
||||||
val schedule = dummyInitializer.createSchedule(
|
|
||||||
status = ScheduleStatus.HOLD,
|
status = ScheduleStatus.HOLD,
|
||||||
isHoldExpired = true
|
isHoldExpired = true
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
runConcurrency(user, schedule)
|
test("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 일정이 만료되었더라도 처리하지 않는다.") {
|
||||||
} catch (e: ReservationException) {
|
val createdReservationId = withContext(Dispatchers.IO) {
|
||||||
e.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE
|
val createPendingReservationJob = async {
|
||||||
|
TransactionTemplate(transactionManager).execute {
|
||||||
|
createPendingReservation(user, schedule)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
val batchJob = async {
|
||||||
|
TransactionTemplate(transactionManager).execute {
|
||||||
|
incompletedReservationScheduler.processExpiredHoldSchedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPendingReservationJob.await().also { batchJob.await() }
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
||||||
|
this.status shouldBe ScheduleStatus.HOLD
|
||||||
|
this.holdExpiredAt.shouldNotBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSoftly(reservationRepository.findByIdOrNull(createdReservationId)) {
|
||||||
|
this.shouldNotBeNull()
|
||||||
|
this.status shouldBe ReservationStatus.PENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Pending 예약 생성 직전에 배치가 시작되면 예약 생성에 실패한다.") {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val batchJob = async {
|
||||||
|
TransactionTemplate(transactionManager).execute {
|
||||||
|
incompletedReservationScheduler.processExpiredHoldSchedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(5)
|
||||||
|
|
||||||
|
val createPendingReservationJob = async {
|
||||||
|
assertThrows<ReservationException> {
|
||||||
|
TransactionTemplate(transactionManager).execute {
|
||||||
|
createPendingReservation(user, schedule)
|
||||||
|
}!!
|
||||||
|
}.also {
|
||||||
|
it.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPendingReservationJob.await().also { batchJob.await() }
|
||||||
|
}
|
||||||
|
|
||||||
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
||||||
this.status shouldBe ScheduleStatus.AVAILABLE
|
this.status shouldBe ScheduleStatus.AVAILABLE
|
||||||
@ -58,32 +104,8 @@ class ReservationConcurrencyTest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("holdExpiredAt이 지나지 않은 일정인 경우, Pending 예약 생성이 정상적으로 종료되며 배치 작업이 적용되지 않는다.") {
|
private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long {
|
||||||
val schedule = dummyInitializer.createSchedule(
|
return reservationService.createPendingReservation(
|
||||||
status = ScheduleStatus.HOLD,
|
|
||||||
isHoldExpired = false
|
|
||||||
)
|
|
||||||
|
|
||||||
val response = runConcurrency(user, schedule)
|
|
||||||
|
|
||||||
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
|
||||||
this.status shouldBe ScheduleStatus.HOLD
|
|
||||||
this.holdExpiredAt.shouldNotBeNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
assertSoftly(reservationRepository.findByIdOrNull(response.id)) {
|
|
||||||
this.shouldNotBeNull()
|
|
||||||
this.status shouldBe ReservationStatus.PENDING
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runConcurrency(user: UserEntity, schedule: ScheduleEntity): PendingReservationCreateResponse {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
val createPendingReservationJob = async {
|
|
||||||
TransactionTemplate(transactionManager).execute {
|
|
||||||
reservationService.createPendingReservation(
|
|
||||||
user = CurrentUserContext(id = user.id, name = user.name),
|
user = CurrentUserContext(id = user.id, name = user.name),
|
||||||
request = PendingReservationCreateRequest(
|
request = PendingReservationCreateRequest(
|
||||||
scheduleId = schedule.id,
|
scheduleId = schedule.id,
|
||||||
@ -92,17 +114,6 @@ class ReservationConcurrencyTest(
|
|||||||
participantCount = 3,
|
participantCount = 3,
|
||||||
requirement = "없어요!"
|
requirement = "없어요!"
|
||||||
)
|
)
|
||||||
)
|
).id
|
||||||
}!!
|
|
||||||
}
|
|
||||||
|
|
||||||
val updateScheduleJob = async {
|
|
||||||
TransactionTemplate(transactionManager).execute {
|
|
||||||
incompletedReservationScheduler.processExpiredHoldSchedule()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createPendingReservationJob.await().also { updateScheduleJob.await() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user