[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 #57

Merged
pricelees merged 45 commits from refactor/#56 into main 2025-10-09 09:33:29 +00:00
5 changed files with 142 additions and 63 deletions
Showing only changes of commit 6fa8c76b87 - Show all commits

View File

@ -42,11 +42,18 @@ class ReservationService(
): PendingReservationCreateResponse { ): PendingReservationCreateResponse {
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
validateCanCreate(request) run {
val schedule = scheduleService.findSummaryWithLock(request.scheduleId)
val theme = themeService.findInfoById(schedule.themeId)
val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id) reservationValidator.validateCanCreate(schedule, theme, request)
}
return PendingReservationCreateResponse(reservationRepository.save(reservation).id) val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id).also {
reservationRepository.save(it)
}
return PendingReservationCreateResponse(reservation.id)
.also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } } .also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
} }
@ -153,10 +160,4 @@ class ReservationService(
} }
} }
private fun validateCanCreate(request: PendingReservationCreateRequest) {
val schedule = scheduleService.findSummaryWithLock(request.scheduleId)
val theme = themeService.findInfoById(schedule.themeId)
reservationValidator.validateCanCreate(schedule, theme, request)
}
} }

View File

@ -1,5 +1,7 @@
package com.sangdol.roomescape.reservation.business package com.sangdol.roomescape.reservation.business
import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.common.utils.toKoreaDateTime
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.web.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest
@ -9,6 +11,8 @@ import com.sangdol.roomescape.theme.web.ThemeInfoResponse
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
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -20,11 +24,31 @@ class ReservationValidator {
theme: ThemeInfoResponse, theme: ThemeInfoResponse,
request: PendingReservationCreateRequest request: PendingReservationCreateRequest
) { ) {
validateSchedule(schedule)
validateReservationInfo(theme, request)
}
private fun validateSchedule(schedule: ScheduleSummaryResponse) {
if (schedule.status != ScheduleStatus.HOLD) { if (schedule.status != ScheduleStatus.HOLD) {
log.warn { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
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.time)
val nowDateTime = KoreaDateTime.now()
if (scheduleDateTime.isBefore(nowDateTime)) {
log.info { "[validateCanCreate] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}" }
throw ReservationException(ReservationErrorCode.PAST_SCHEDULE)
}
}
private fun validateReservationInfo(theme: ThemeInfoResponse, request: PendingReservationCreateRequest) {
if (theme.minParticipants > request.participantCount) { if (theme.minParticipants > request.participantCount) {
log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)

View File

@ -12,6 +12,7 @@ enum class ReservationErrorCode(
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."), NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."), EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."),
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.") INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요."),
PAST_SCHEDULE(HttpStatus.BAD_REQUEST, "R006", "지난 일정은 예약할 수 없어요.")
; ;
} }

View File

@ -2,6 +2,8 @@ package com.sangdol.roomescape.reservation
import com.sangdol.common.types.exception.CommonErrorCode import com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.utils.KoreaDate
import com.sangdol.common.utils.KoreaTime
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.payment.infrastructure.common.BankCode import com.sangdol.roomescape.payment.infrastructure.common.BankCode
import com.sangdol.roomescape.payment.infrastructure.common.CardIssuerCode import com.sangdol.roomescape.payment.infrastructure.common.CardIssuerCode
@ -19,7 +21,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleReposi
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.supports.*
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe import io.kotest.matchers.shouldNotBe
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
@ -31,7 +32,6 @@ import java.time.LocalTime
class ReservationApiTest( class ReservationApiTest(
private val reservationRepository: ReservationRepository, private val reservationRepository: ReservationRepository,
private val canceledReservationRepository: CanceledReservationRepository, private val canceledReservationRepository: CanceledReservationRepository,
private val themeRepository: ThemeRepository,
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
private val paymentDetailRepository: PaymentDetailRepository, private val paymentDetailRepository: PaymentDetailRepository,
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
@ -91,18 +91,46 @@ class ReservationApiTest(
test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") {
val schedule: ScheduleEntity = dummyInitializer.createSchedule(status = ScheduleStatus.AVAILABLE) val schedule: ScheduleEntity = dummyInitializer.createSchedule(status = ScheduleStatus.AVAILABLE)
runTest( runExceptionTest(
token = testAuthUtil.defaultUserLogin().second, token = testAuthUtil.defaultUserLogin().second,
using = { method = HttpMethod.POST,
body(commonRequest.copy(scheduleId = schedule.id)) endpoint = endpoint,
}, requestBody = commonRequest.copy(scheduleId = schedule.id),
on = { expectedErrorCode = ReservationErrorCode.EXPIRED_HELD_SCHEDULE
post(endpoint) )
},
expect = {
statusCode(HttpStatus.CONFLICT.value())
body("code", equalTo(ReservationErrorCode.EXPIRED_HELD_SCHEDULE.errorCode))
} }
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("현재 시간이 일정의 시작 시간 이후이면 실패한다.") {
val schedule: ScheduleEntity = dummyInitializer.createSchedule(
status = ScheduleStatus.HOLD,
isHoldExpired = false,
request = ScheduleFixture.createRequest.copy(
date = KoreaDate.today(),
time = KoreaTime.now()
)
)
runExceptionTest(
token = testAuthUtil.defaultUserLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
requestBody = commonRequest.copy(scheduleId = schedule.id),
expectedErrorCode = ReservationErrorCode.PAST_SCHEDULE
) )
} }
@ -120,7 +148,7 @@ class ReservationApiTest(
method = HttpMethod.POST, method = HttpMethod.POST,
endpoint = endpoint, endpoint = endpoint,
requestBody = commonRequest.copy( requestBody = commonRequest.copy(
schedule.id, scheduleId = schedule.id,
participantCount = ((theme.minParticipants - 1).toShort()) participantCount = ((theme.minParticipants - 1).toShort())
), ),
expectedErrorCode = ReservationErrorCode.INVALID_PARTICIPANT_COUNT expectedErrorCode = ReservationErrorCode.INVALID_PARTICIPANT_COUNT

View File

@ -3,24 +3,26 @@ 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.exception.ReservationErrorCode
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.web.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.web.PendingReservationCreateResponse import com.sangdol.roomescape.reservation.web.PendingReservationCreateResponse
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
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.matchers.nulls.shouldNotBeNull 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.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
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
import java.time.Instant
class ReservationConcurrencyTest( class ReservationConcurrencyTest(
private val transactionManager: PlatformTransactionManager, private val transactionManager: PlatformTransactionManager,
@ -31,43 +33,38 @@ class ReservationConcurrencyTest(
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
init { init {
test("Pending 예약 생성시, Schedule 상태 검증 이후부터 커밋 이전 사이에 시작된 schedule 처리 배치 작업은 반영되지 않는다.") { context("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 로직의 완료 이후에 처리된다.") {
val user = testAuthUtil.defaultUserLogin().first lateinit var user: UserEntity
val schedule = dummyInitializer.createSchedule().also {
it.status = ScheduleStatus.HOLD
it.holdExpiredAt = Instant.now().minusSeconds(1 * 60)
scheduleRepository.save(it)
}
lateinit var response: PendingReservationCreateResponse
withContext(Dispatchers.IO) { beforeTest {
val createPendingReservationJob = async { user = testAuthUtil.defaultUserLogin().first
response = TransactionTemplate(transactionManager).execute { }
val response = reservationService.createPendingReservation(
user = CurrentUserContext(id = user.id, name = user.name), test("holdExpiredAt이 지난 일정인 경우, Pending 예약 생성 중 예외가 발생하고 이후 배치가 재활성화 처리한다.") {
request = PendingReservationCreateRequest( val schedule = dummyInitializer.createSchedule(
scheduleId = schedule.id, status = ScheduleStatus.HOLD,
reserverName = user.name, isHoldExpired = true
reserverContact = user.phone,
participantCount = 3,
requirement = "없어요!"
)
) )
Thread.sleep(200) try {
response runConcurrency(user, schedule)
}!! } catch (e: ReservationException) {
} e.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE
val updateScheduleJob = async { assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
TransactionTemplate(transactionManager).execute { this.status shouldBe ScheduleStatus.AVAILABLE
incompletedReservationScheduler.processExpiredHoldSchedule() this.holdExpiredAt shouldBe null
}
} }
} }
listOf(createPendingReservationJob, updateScheduleJob).awaitAll() test("holdExpiredAt이 지나지 않은 일정인 경우, Pending 예약 생성이 정상적으로 종료되며 배치 작업이 적용되지 않는다.") {
} val schedule = dummyInitializer.createSchedule(
status = ScheduleStatus.HOLD,
isHoldExpired = false
)
val response = runConcurrency(user, schedule)
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
this.status shouldBe ScheduleStatus.HOLD this.status shouldBe ScheduleStatus.HOLD
@ -80,4 +77,32 @@ class ReservationConcurrencyTest(
} }
} }
} }
}
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),
request = PendingReservationCreateRequest(
scheduleId = schedule.id,
reserverName = user.name,
reserverContact = user.phone,
participantCount = 3,
requirement = "없어요!"
)
)
}!!
}
val updateScheduleJob = async {
TransactionTemplate(transactionManager).execute {
incompletedReservationScheduler.processExpiredHoldSchedule()
}
}
createPendingReservationJob.await().also { updateScheduleJob.await() }
}
}
} }