From a1621e2f65047c1a2afbbaa63ce1eb121fbbc2eb Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 20:59:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=84=EB=A3=8C=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EC=98=88=EC=95=BD=20=EB=B0=8F=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=B2=98=EB=A6=AC=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=EB=9F=AC=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IncompletedReservationScheduler.kt | 43 +++++++++++ .../persistence/ReservationRepository.kt | 20 +++++ .../persistence/ScheduleRepository.kt | 22 ++++++ .../IncompletedReservationSchedulerTest.kt | 73 +++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt new file mode 100644 index 00000000..dbfcc641 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt @@ -0,0 +1,43 @@ +package com.sangdol.roomescape.reservation.business.scheduler + +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +private val log: KLogger = KotlinLogging.logger {} + +@Component +@EnableScheduling +class IncompletedReservationScheduler( + private val scheduleRepository: ScheduleRepository, + private val reservationRepository: ReservationRepository +) { + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Transactional + fun processExpiredHoldSchedule() { + log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } + + scheduleRepository.releaseExpiredHolds(LocalDateTime.now()).also { + log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } + } + } + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Transactional + fun processExpiredReservation() { + log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } + + reservationRepository.expirePendingReservations(LocalDateTime.now()).also { + log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" } + } + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index e9dbdcde..9ef52754 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -1,8 +1,28 @@ package com.sangdol.roomescape.reservation.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime interface ReservationRepository : JpaRepository { fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List): List + + @Modifying + @Query(""" + UPDATE + reservation r + JOIN + schedule s ON r.schedule_id = s.id AND s.status = 'HOLD' + SET + r.status = 'EXPIRED', + r.updated_at = :now, + s.status = 'AVAILABLE', + s.hold_expired_at = NULL + WHERE + r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) + """, nativeQuery = true) + fun expirePendingReservations(@Param("now") now: LocalDateTime): Int } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt index ceca6f16..48e75cd3 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt @@ -4,7 +4,9 @@ import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime interface ScheduleRepository : JpaRepository { @@ -103,4 +105,24 @@ interface ScheduleRepository : JpaRepository { """ ) fun changeStatus(id: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus): Int + + @Modifying + @Query( + """ + UPDATE + ScheduleEntity s + SET + s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE, + s.holdExpiredAt = NULL + WHERE + s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD + AND s.holdExpiredAt <= :now + AND NOT EXISTS ( + SELECT 1 + FROM ReservationEntity r + WHERE r.scheduleId = s._id + ) + """ + ) + fun releaseExpiredHolds(@Param("now") now: LocalDateTime): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt new file mode 100644 index 00000000..b3eaabed --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt @@ -0,0 +1,73 @@ +package com.sangdol.roomescape.reservation.business + +import com.sangdol.common.persistence.TransactionExecutionUtil +import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler +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.user.infrastructure.persistence.UserEntity +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import org.springframework.data.repository.findByIdOrNull +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime + +/** + * @see com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler + * Scheduling 시간은 테스트 하지 않고, 내부 로직의 정상 동작만 확인 + */ +class IncompletedReservationSchedulerTest( + private val incompletedReservationScheduler: IncompletedReservationScheduler, + private val transactionExecutionUtil: TransactionExecutionUtil, + private val jdbcTemplate: JdbcTemplate, + private val reservationRepository: ReservationRepository, + private val scheduleRepository: ScheduleRepository, +) : FunSpecSpringbootTest() { + + init { + test("예약이 없고, hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") { + val schedule: ScheduleEntity = dummyInitializer.createSchedule().apply { + this.status = ScheduleStatus.HOLD + this.holdExpiredAt = LocalDateTime.now().minusSeconds(1) + }.also { + scheduleRepository.saveAndFlush(it) + } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + incompletedReservationScheduler.processExpiredHoldSchedule() + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null + } + } + + test("${ReservationStatus.PENDING} 상태로 일정 시간 완료되지 않은 예약을 ${ReservationStatus.EXPIRED} 상태로 바꾼다.") { + val user: UserEntity = testAuthUtil.defaultUserLogin().first + val reservation = dummyInitializer.createPendingReservation(user = user).also { + jdbcTemplate.execute("UPDATE reservation SET created_at = DATE_SUB(NOW(), INTERVAL 5 MINUTE) WHERE id = ${it.id}") + } + + val now = LocalDateTime.now() + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + incompletedReservationScheduler.processExpiredReservation() + } + + assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { + this.status shouldBe ReservationStatus.EXPIRED + this.updatedAt.hour shouldBe now.hour + this.updatedAt.minute shouldBe now.minute + } + + assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null + } + } + } +}