generated from pricelees/issue-pr-template
[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 #57
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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", "지난 일정은 예약할 수 없어요.")
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -81,3 +78,31 @@ 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user