From 1c700130c44b1b4e603db8c3f897d25a8b5f1e08 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 6 Oct 2025 16:40:11 +0900 Subject: [PATCH 01/45] =?UTF-8?q?refactor:=20ScheduleValidator=EC=9D=98=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=A7=A4=EC=8B=9C=EC=A7=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/schedule/business/ScheduleValidator.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt index a9387348..1ce56192 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt @@ -26,7 +26,7 @@ class ScheduleValidator( val status: ScheduleStatus = schedule.status if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) { - log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" } + log.info { "[validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" } throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE) } } @@ -51,7 +51,7 @@ class ScheduleValidator( private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) { if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) { log.info { - "[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}" + "[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}" } throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS) } @@ -63,7 +63,7 @@ class ScheduleValidator( if (inputDateTime.isBefore(now)) { log.info { - "[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}" + "[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}" } throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) } @@ -73,7 +73,7 @@ class ScheduleValidator( scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId) .firstOrNull { it.containsTime(time) } ?.let { - log.info { "[ScheduleValidator.validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" } + log.info { "[validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" } throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT) } } -- 2.47.2 From 44c556776d0af38c72ebaf26d37755e8c9961e6e Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 6 Oct 2025 16:49:58 +0900 Subject: [PATCH 02/45] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20HOLD=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 충돌 방지를 위해 조회시 Lock 추가 - 해당 일정의 시작 시간이 현재 시간 이후인지 검증 로직 추가 --- .../schedule/business/ScheduleService.kt | 32 ++++++++++++------- .../schedule/business/ScheduleValidator.kt | 9 ++++++ .../roomescape/schedule/ScheduleApiTest.kt | 18 ++++++++++- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 95be6d49..2dbade7b 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -71,19 +71,18 @@ class ScheduleService( @Transactional fun holdSchedule(id: Long) { log.info { "[holdSchedule] 일정 Holding 시작: id=$id" } - val result: Int = scheduleRepository.changeStatus( - id = id, - currentStatus = ScheduleStatus.AVAILABLE, + + val schedule = findForUpdateOrThrow(id).also { + scheduleValidator.validateCanHold(it) + } + + scheduleRepository.changeStatus( + id = schedule.id, + currentStatus = schedule.status, changeStatus = ScheduleStatus.HOLD ).also { - log.info { "[holdSchedule] $it 개의 row 변경 완료" } + log.info { "[holdSchedule] 일정 Holding 완료: id=$id" } } - - if (result == 0) { - throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) - } - - log.info { "[holdSchedule] 일정 Holding 완료: id=$id" } } // ======================================== @@ -222,7 +221,18 @@ class ScheduleService( return scheduleRepository.findByIdOrNull(id) ?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } } ?: run { - log.warn { "[updateSchedule] 일정 조회 실패. id=$id" } + log.warn { "[findOrThrow] 일정 조회 실패. id=$id" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) + } + } + + private fun findForUpdateOrThrow(id: Long): ScheduleEntity { + log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" } + + return scheduleRepository.findByIdForUpdate(id) + ?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } } + ?: run { + log.warn { "[findForUpdateOrThrow] 일정 조회 실패. id=$id" } throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt index 1ce56192..5e0f5e1d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt @@ -22,6 +22,15 @@ private val log: KLogger = KotlinLogging.logger {} class ScheduleValidator( private val scheduleRepository: ScheduleRepository ) { + fun validateCanHold(schedule: ScheduleEntity) { + if (schedule.status != ScheduleStatus.AVAILABLE) { + log.info { "[validateCanHold] HOLD 실패: id=${schedule.id}, status=${schedule.status}" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) + } + + validateNotInPast(schedule.date, schedule.time) + } + fun validateCanDelete(schedule: ScheduleEntity) { val status: ScheduleStatus = schedule.status diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt index 2ad2c5a6..94f8d4e8 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt @@ -155,12 +155,28 @@ class ScheduleApiTest( } } + test("해당 일정의 시작 시간이 현재 시간 이전이면 실패한다.") { + val schedule = dummyInitializer.createSchedule( + request = ScheduleFixture.createRequest.copy( + date = KoreaDate.today(), + time = KoreaTime.now().minusMinutes(1) + ) + ) + + runExceptionTest( + token = testAuthUtil.defaultUserLogin().second, + method = HttpMethod.POST, + endpoint = "/schedules/${schedule.id}/hold", + expectedErrorCode = ScheduleErrorCode.PAST_DATE_TIME + ) + } + test("일정이 없으면 실패한다.") { runExceptionTest( token = testAuthUtil.defaultUserLogin().second, method = HttpMethod.POST, endpoint = "/schedules/${INVALID_PK}/hold", - expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE + expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND ) } } -- 2.47.2 From 5bcba12a612bfcf438da02787944cb0732e4f576 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 6 Oct 2025 16:52:48 +0900 Subject: [PATCH 03/45] =?UTF-8?q?refactor:=20ReservationValidator=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=A7=A4=EC=8B=9C=EC=A7=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/reservation/business/ReservationValidator.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 84fea83b..9eb230aa 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -21,17 +21,17 @@ class ReservationValidator { request: PendingReservationCreateRequest ) { if (schedule.status != ScheduleStatus.HOLD) { - log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } + log.warn { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) } if (theme.minParticipants > request.participantCount) { - log.info { "[ReservationValidator.validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } + log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) } if (theme.maxParticipants < request.participantCount) { - log.info { "[ReservationValidator.validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } + log.info { "[validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) } } -- 2.47.2 From 8378e10192af1485ed01d46233b3c7efafdd60ff Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 6 Oct 2025 16:53:43 +0900 Subject: [PATCH 04/45] =?UTF-8?q?refactor:=20Pending=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=BC=EC=A0=95=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20ReservationService=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20ScheduleSummaryResponse?= =?UTF-8?q?=EC=97=90=20holdExpiredAt=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sangdol/roomescape/schedule/web/ScheduleDto.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt index 9085c299..17da9b83 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt @@ -4,6 +4,7 @@ import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty +import java.time.Instant import java.time.LocalDate import java.time.LocalTime @@ -45,14 +46,16 @@ data class ScheduleSummaryResponse( val date: LocalDate, val time: LocalTime, val themeId: Long, - val status: ScheduleStatus + val status: ScheduleStatus, + val holdExpiredAt: Instant? = null ) fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse( date = this.date, time = this.time, themeId = this.themeId, - status = this.status + status = this.status, + holdExpiredAt = this.holdExpiredAt ) data class ScheduleOverviewResponse( -- 2.47.2 From 022742d1facd07d79dd11810d9ec18be396a7306 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 6 Oct 2025 17:33:01 +0900 Subject: [PATCH 05/45] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=9E=AC=ED=99=9C=EC=84=B1=ED=99=94=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A5=B4=EB=A7=81=20=EC=9E=91=EC=97=85=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EC=9D=B4=20=EC=97=86=EB=8A=94=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20->=20PENDING=20=EC=98=88=EC=95=BD=EC=9D=B4=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9D=BC=EC=A0=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/ScheduleRepository.kt | 2 +- .../IncompletedReservationSchedulerTest.kt | 27 +++++++++++++++++++ .../roomescape/supports/DummyInitializer.kt | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) 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 87a38a58..08f9ffb3 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 @@ -140,7 +140,7 @@ interface ScheduleRepository : JpaRepository { AND NOT EXISTS ( SELECT 1 FROM ReservationEntity r - WHERE r.scheduleId = s._id + WHERE r.scheduleId = s._id AND r.status = com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus.PENDING ) """ ) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt index c317525f..4b2db0d9 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt @@ -8,6 +8,7 @@ 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.initialize import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import io.kotest.assertions.assertSoftly import io.kotest.matchers.shouldBe @@ -47,6 +48,32 @@ class IncompletedReservationSchedulerTest( } } + test("예약이 있어도, 해당 예약이 ${ReservationStatus.PENDING}이 상태가 아니면 hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") { + val schedule = initialize("취소된 예약 추가") { + val reservation = dummyInitializer.createConfirmReservation( + user = testAuthUtil.defaultUserLogin().first, + ).also { + it.status = ReservationStatus.CANCELED + reservationRepository.saveAndFlush(it) + } + + scheduleRepository.findByIdOrNull(reservation.scheduleId)!!.apply { + this.status = ScheduleStatus.HOLD + this.holdExpiredAt = Instant.now().minusSeconds(1) + scheduleRepository.saveAndFlush(this) + } + } + + 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 { diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index 3e71288a..7560805f 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -147,6 +147,7 @@ class DummyInitializer( scheduleRepository.findByIdOrNull(it.scheduleId)?.let { schedule -> schedule.status = ScheduleStatus.RESERVED + schedule.holdExpiredAt = null scheduleRepository.save(schedule) } } -- 2.47.2 From 0a7bd85dc9b07fec9163883158845d5038112c48 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 6 Oct 2025 17:49:51 +0900 Subject: [PATCH 06/45] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20schedule=20=EB=8D=94=EB=AF=B8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=EC=97=90=20?= =?UTF-8?q?holdExpiredAt=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/IncompletedReservationSchedulerTest.kt | 10 ++++------ .../sangdol/roomescape/supports/DummyInitializer.kt | 10 +++++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt index 4b2db0d9..b70e0658 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt @@ -31,12 +31,10 @@ class IncompletedReservationSchedulerTest( init { test("예약이 없고, hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") { - val schedule: ScheduleEntity = dummyInitializer.createSchedule().apply { - this.status = ScheduleStatus.HOLD - this.holdExpiredAt = Instant.now().minusSeconds(1) - }.also { - scheduleRepository.saveAndFlush(it) - } + val schedule: ScheduleEntity = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = true + ) transactionExecutionUtil.withNewTransaction(isReadOnly = false) { incompletedReservationScheduler.processExpiredHoldSchedule() diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index 7560805f..e8bd060f 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -76,7 +76,8 @@ class DummyInitializer( fun createSchedule( storeId: Long = IDGenerator.create(), request: ScheduleCreateRequest = ScheduleFixture.createRequest, - status: ScheduleStatus = ScheduleStatus.AVAILABLE + status: ScheduleStatus = ScheduleStatus.AVAILABLE, + isHoldExpired: Boolean = false ): ScheduleEntity { val themeId: Long = if (themeRepository.existsById(request.themeId)) { request.themeId @@ -94,6 +95,13 @@ class DummyInitializer( date = request.date, time = request.time, storeId = storeId, themeId = themeId, ).apply { this.status = status + if (status == ScheduleStatus.HOLD) { + if (isHoldExpired) { + this.holdExpiredAt = Instant.now().minusSeconds(1 * 60) + } else { + this.holdExpiredAt = Instant.now().plusSeconds(1 * 60) + } + } } return scheduleRepository.save(schedule) -- 2.47.2 From 6fa8c76b876f8d247c342b9a6ac68fd2b2df46be Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 6 Oct 2025 17:56:52 +0900 Subject: [PATCH 07/45] =?UTF-8?q?refactor:=20Pending=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=8B=9C=20validation=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 해당 schedule이 만료되었는지, 시작 시간이 현재 시간 이전인지 확인 --- .../business/ReservationService.kt | 19 ++-- .../business/ReservationValidator.kt | 26 ++++- .../exception/ReservationErrorCode.kt | 3 +- .../reservation/ReservationApiTest.kt | 56 +++++++--- .../reservation/ReservationConcurrencyTest.kt | 101 +++++++++++------- 5 files changed, 142 insertions(+), 63 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index 315e1b6f..00ef7fa7 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -42,11 +42,18 @@ class ReservationService( ): PendingReservationCreateResponse { 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}" } } } @@ -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) - } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 9eb230aa..fb410b13 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -1,5 +1,7 @@ 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.ReservationException 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.KotlinLogging import org.springframework.stereotype.Component +import java.time.Instant +import java.time.LocalDateTime private val log: KLogger = KotlinLogging.logger {} @@ -20,11 +24,31 @@ class ReservationValidator { theme: ThemeInfoResponse, request: PendingReservationCreateRequest ) { + validateSchedule(schedule) + validateReservationInfo(theme, request) + } + private fun validateSchedule(schedule: ScheduleSummaryResponse) { if (schedule.status != ScheduleStatus.HOLD) { - log.warn { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } + log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } 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) { log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt index 82158113..e68c91ea 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt @@ -12,6 +12,7 @@ enum class ReservationErrorCode( NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."), 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", "지난 일정은 예약할 수 없어요.") ; } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt index 9d255626..79bf9a00 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -2,6 +2,8 @@ package com.sangdol.roomescape.reservation import com.sangdol.common.types.exception.CommonErrorCode 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.payment.infrastructure.common.BankCode 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.supports.* 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.shouldNotBe import org.hamcrest.CoreMatchers.equalTo @@ -31,7 +32,6 @@ import java.time.LocalTime class ReservationApiTest( private val reservationRepository: ReservationRepository, private val canceledReservationRepository: CanceledReservationRepository, - private val themeRepository: ThemeRepository, private val scheduleRepository: ScheduleRepository, private val paymentDetailRepository: PaymentDetailRepository, ) : FunSpecSpringbootTest() { @@ -91,18 +91,46 @@ class ReservationApiTest( test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule(status = ScheduleStatus.AVAILABLE) - runTest( + runExceptionTest( token = testAuthUtil.defaultUserLogin().second, - using = { - body(commonRequest.copy(scheduleId = schedule.id)) - }, - on = { - post(endpoint) - }, - expect = { - statusCode(HttpStatus.CONFLICT.value()) - body("code", equalTo(ReservationErrorCode.EXPIRED_HELD_SCHEDULE.errorCode)) - } + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy(scheduleId = schedule.id), + expectedErrorCode = ReservationErrorCode.EXPIRED_HELD_SCHEDULE + ) + } + + 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, endpoint = endpoint, requestBody = commonRequest.copy( - schedule.id, + scheduleId = schedule.id, participantCount = ((theme.minParticipants - 1).toShort()) ), expectedErrorCode = ReservationErrorCode.INVALID_PARTICIPANT_COUNT diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt index 75fc7279..005f8a5b 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -3,24 +3,26 @@ package com.sangdol.roomescape.reservation import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.reservation.business.ReservationService 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.ReservationStatus import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest 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.ScheduleStatus import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import io.kotest.assertions.assertSoftly import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import org.springframework.data.repository.findByIdOrNull import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate -import java.time.Instant class ReservationConcurrencyTest( private val transactionManager: PlatformTransactionManager, @@ -31,53 +33,76 @@ class ReservationConcurrencyTest( ) : FunSpecSpringbootTest() { init { - test("Pending 예약 생성시, Schedule 상태 검증 이후부터 커밋 이전 사이에 시작된 schedule 처리 배치 작업은 반영되지 않는다.") { - val user = testAuthUtil.defaultUserLogin().first - val schedule = dummyInitializer.createSchedule().also { - it.status = ScheduleStatus.HOLD - it.holdExpiredAt = Instant.now().minusSeconds(1 * 60) - scheduleRepository.save(it) + context("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 로직의 완료 이후에 처리된다.") { + lateinit var user: UserEntity + + beforeTest { + user = testAuthUtil.defaultUserLogin().first } - lateinit var response: PendingReservationCreateResponse - withContext(Dispatchers.IO) { - val createPendingReservationJob = async { - response = TransactionTemplate(transactionManager).execute { - val response = reservationService.createPendingReservation( - user = CurrentUserContext(id = user.id, name = user.name), - request = PendingReservationCreateRequest( - scheduleId = schedule.id, - reserverName = user.name, - reserverContact = user.phone, - participantCount = 3, - requirement = "없어요!" - ) - ) + test("holdExpiredAt이 지난 일정인 경우, Pending 예약 생성 중 예외가 발생하고 이후 배치가 재활성화 처리한다.") { + val schedule = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = true + ) - Thread.sleep(200) - response - }!! - } + try { + runConcurrency(user, schedule) + } catch (e: ReservationException) { + e.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE - val updateScheduleJob = async { - TransactionTemplate(transactionManager).execute { - incompletedReservationScheduler.processExpiredHoldSchedule() + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null } } - - listOf(createPendingReservationJob, updateScheduleJob).awaitAll() } + test("holdExpiredAt이 지나지 않은 일정인 경우, Pending 예약 생성이 정상적으로 종료되며 배치 작업이 적용되지 않는다.") { + val schedule = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = false + ) - assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.HOLD - this.holdExpiredAt.shouldNotBeNull() - } + val response = runConcurrency(user, schedule) - assertSoftly(reservationRepository.findByIdOrNull(response.id)) { - this.shouldNotBeNull() - this.status shouldBe ReservationStatus.PENDING + 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), + 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() } + } + } } -- 2.47.2 From b22d587757f8e8f23801dcdb8add22a58ce0bcfd Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 16:08:25 +0900 Subject: [PATCH 08/45] =?UTF-8?q?refactor:=20WebLogMessageConverter?= =?UTF-8?q?=EC=97=90=20=EC=98=88=EC=99=B8=20=EC=83=81=ED=99=A9=EC=9D=84=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=98=EB=8A=94=20=EB=B3=84=EB=8F=84?= =?UTF-8?q?=EC=9D=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=EC=A1=B4=20ExceptionHandler=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/exception/GlobalExceptionhandler.kt | 7 ++---- .../web/support/log/WebLogMessageConverter.kt | 16 ++++++++++++++ .../support/log/WebLogMessageConverterTest.kt | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt b/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt index a790a4d2..cab24523 100644 --- a/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt +++ b/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt @@ -1,6 +1,5 @@ package com.sangdol.common.web.exception -import com.sangdol.common.log.constant.LogType import com.sangdol.common.types.exception.CommonErrorCode import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.RoomescapeException @@ -88,13 +87,11 @@ class GlobalExceptionHandler( errorResponse: CommonErrorResponse, exception: Exception ) { - val type = if (httpStatus.isClientError()) LogType.APPLICATION_FAILURE else LogType.UNHANDLED_EXCEPTION val actualException: Exception? = if (errorResponse.message == exception.message) null else exception - val logMessage = messageConverter.convertToResponseMessage( - type = type, + val logMessage = messageConverter.convertToErrorResponseMessage( servletRequest = servletRequest, - httpStatusCode = httpStatus.value(), + httpStatus = httpStatus, responseBody = errorResponse, exception = actualException ) diff --git a/common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt index 48653a11..3e856dea 100644 --- a/common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt +++ b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt @@ -2,6 +2,7 @@ package com.sangdol.common.web.support.log import com.fasterxml.jackson.databind.ObjectMapper import com.sangdol.common.log.constant.LogType +import com.sangdol.common.types.web.HttpStatus import jakarta.servlet.http.HttpServletRequest class WebLogMessageConverter( @@ -49,4 +50,19 @@ class WebLogMessageConverter( return objectMapper.writeValueAsString(payload) } + + fun convertToErrorResponseMessage( + servletRequest: HttpServletRequest, + httpStatus: HttpStatus, + responseBody: Any? = null, + exception: Exception? = null, + ): String { + val type = if (httpStatus.isClientError()) { + LogType.APPLICATION_FAILURE + } else { + LogType.UNHANDLED_EXCEPTION + } + + return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception) + } } diff --git a/common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt index 4c0951fb..613e104f 100644 --- a/common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt +++ b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt @@ -168,5 +168,27 @@ class WebLogMessageConverterTest : FunSpec({ this["exception"] shouldBe null } } + + test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") { + val result = converter.convertToErrorResponseMessage( + servletRequest = servletRequest, + httpStatus = HttpStatus.BAD_REQUEST, + ) + + assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) { + this["type"] shouldBe LogType.APPLICATION_FAILURE.name + } + } + + test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") { + val result = converter.convertToErrorResponseMessage( + servletRequest = servletRequest, + httpStatus = HttpStatus.INTERNAL_SERVER_ERROR, + ) + + assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) { + this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name + } + } } }) -- 2.47.2 From 1652398fcc3304d294b3d542796734c537da022a Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 16:09:51 +0900 Subject: [PATCH 09/45] =?UTF-8?q?refactor:=20TosspayClient=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=EB=B3=84=EB=8F=84?= =?UTF-8?q?=EC=9D=98=20ExternalPaymentException=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/exception/PaymentException.kt | 6 ++++++ .../infrastructure/client/TosspayClient.kt | 10 +++++++-- .../roomescape/payment/TosspayClientTest.kt | 21 ++++++++++--------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentException.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentException.kt index 3bddd08c..87f7965d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentException.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentException.kt @@ -6,3 +6,9 @@ class PaymentException( override val errorCode: PaymentErrorCode, override val message: String = errorCode.message ) : RoomescapeException(errorCode, message) + +class ExternalPaymentException( + val httpStatusCode: Int, + val errorCode: String, + override val message: String +) : RuntimeException(message) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt index 49194b30..8840bd20 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt @@ -1,6 +1,7 @@ package com.sangdol.roomescape.payment.infrastructure.client import com.fasterxml.jackson.databind.ObjectMapper +import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException import io.github.oshai.kotlinlogging.KLogger @@ -138,9 +139,14 @@ private class TosspayErrorHandler( response: ClientHttpResponse ): Nothing { val requestType: String = paymentRequestType(url) - log.warn { "[TosspayClient] $requestType 요청 실패: response: ${parseResponse(response)}" } + val errorResponse: TosspayErrorResponse = parseResponse(response) + log.warn { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" } - throw PaymentException(paymentErrorCode(response.statusCode)) + throw ExternalPaymentException( + httpStatusCode = response.statusCode.value(), + errorCode = errorResponse.code, + message = errorResponse.message + ) } private fun paymentRequestType(url: URI): String { diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt index 6ad61b04..c9c54396 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt @@ -1,6 +1,7 @@ package com.sangdol.roomescape.payment import com.ninjasquad.springmockk.MockkBean +import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse @@ -67,7 +68,7 @@ class TosspayClientTest( } context("실패 응답") { - fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) { + fun runTest(httpStatus: HttpStatus) { commonAction().andRespond { withStatus(httpStatus) .contentType(MediaType.APPLICATION_JSON) @@ -75,7 +76,7 @@ class TosspayClientTest( .createResponse(it) } - val exception = shouldThrow { + val exception = shouldThrow { client.confirm( SampleTosspayConstant.PAYMENT_KEY, SampleTosspayConstant.ORDER_ID, @@ -83,15 +84,15 @@ class TosspayClientTest( ) } - exception.errorCode shouldBe expectedError + exception.errorCode shouldBe "ERROR_CODE" } test("결제 서버에서 4XX 응답 시") { - runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR) + runTest(HttpStatus.BAD_REQUEST) } test("결제 서버에서 5XX 응답 시") { - runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR) + runTest(HttpStatus.INTERNAL_SERVER_ERROR) } } } @@ -131,7 +132,7 @@ class TosspayClientTest( } context("실패 응답") { - fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) { + fun runTest(httpStatus: HttpStatus) { commonAction().andRespond { withStatus(httpStatus) .contentType(MediaType.APPLICATION_JSON) @@ -139,22 +140,22 @@ class TosspayClientTest( .createResponse(it) } - val exception = shouldThrow { + val exception = shouldThrow { client.cancel( SampleTosspayConstant.PAYMENT_KEY, SampleTosspayConstant.AMOUNT, SampleTosspayConstant.CANCEL_REASON ) } - exception.errorCode shouldBe expectedError + exception.errorCode shouldBe "ERROR_CODE" } test("결제 서버에서 4XX 응답 시") { - runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR) + runTest(HttpStatus.BAD_REQUEST) } test("결제 서버에서 5XX 응답 시") { - runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR) + runTest(HttpStatus.INTERNAL_SERVER_ERROR) } } } -- 2.47.2 From fd96bd993962c18069e61bca79781bef873ed07f Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 16:20:55 +0900 Subject: [PATCH 10/45] =?UTF-8?q?refactor:=20PaymentClientConfirmResponse?= =?UTF-8?q?=EC=97=90=20paymentKey,=20paymentType=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sangdol/roomescape/payment/business/PaymentService.kt | 2 -- .../sangdol/roomescape/payment/business/PaymentWriter.kt | 7 +------ .../payment/infrastructure/client/TosspayConfirmDTO.kt | 8 ++++---- .../com/sangdol/roomescape/payment/web/PaymentDTO.kt | 1 - .../com/sangdol/roomescape/supports/DummyInitializer.kt | 3 +-- .../kotlin/com/sangdol/roomescape/supports/Fixtures.kt | 6 ++++-- 6 files changed, 10 insertions(+), 17 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index ec874e10..654346f9 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -34,8 +34,6 @@ class PaymentService( return transactionExecutionUtil.withNewTransaction(isReadOnly = false) { val payment: PaymentEntity = paymentWriter.createPayment( reservationId = reservationId, - orderId = request.orderId, - paymentType = request.paymentType, paymentClientConfirmResponse = clientConfirmResponse ) val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt index bbeda832..a8f14a9f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt @@ -5,7 +5,6 @@ import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.infrastructure.client.* import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod -import com.sangdol.roomescape.payment.infrastructure.common.PaymentType import com.sangdol.roomescape.payment.infrastructure.persistence.* import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging @@ -24,15 +23,11 @@ class PaymentWriter( fun createPayment( reservationId: Long, - orderId: String, - paymentType: PaymentType, paymentClientConfirmResponse: PaymentClientConfirmResponse ): PaymentEntity { log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" } - return paymentClientConfirmResponse.toEntity( - id = idGenerator.create(), reservationId, orderId, paymentType - ).also { + return paymentClientConfirmResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also { paymentRepository.save(it) log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayConfirmDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayConfirmDTO.kt index ca8f549a..55258c22 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayConfirmDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayConfirmDTO.kt @@ -11,6 +11,8 @@ import java.time.OffsetDateTime data class PaymentClientConfirmResponse( val paymentKey: String, + val orderId: String, + val type: PaymentType, val status: PaymentStatus, val totalAmount: Int, val vat: Int, @@ -26,17 +28,15 @@ data class PaymentClientConfirmResponse( fun PaymentClientConfirmResponse.toEntity( id: Long, reservationId: Long, - orderId: String, - paymentType: PaymentType ) = PaymentEntity( id = id, reservationId = reservationId, paymentKey = this.paymentKey, - orderId = orderId, + orderId = this.orderId, totalAmount = this.totalAmount, requestedAt = this.requestedAt.toInstant(), approvedAt = this.approvedAt.toInstant(), - type = paymentType, + type = this.type, method = this.method, status = this.status, ) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt index 0ed997b0..bb94d916 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt @@ -12,7 +12,6 @@ data class PaymentConfirmRequest( val paymentKey: String, val orderId: String, val amount: Int, - val paymentType: PaymentType ) data class PaymentCreateResponse( diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index e8bd060f..d4422ae6 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -180,6 +180,7 @@ class DummyInitializer( val clientConfirmResponse = PaymentFixture.confirmResponse( paymentKey = request.paymentKey, + orderId = request.orderId, amount = request.amount, method = method, cardDetail = cardDetail, @@ -189,8 +190,6 @@ class DummyInitializer( val payment = paymentWriter.createPayment( reservationId = reservationId, - orderId = request.orderId, - paymentType = request.paymentType, paymentClientConfirmResponse = clientConfirmResponse ) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 27ef1215..750d94a3 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -241,7 +241,6 @@ object PaymentFixture { paymentKey = "paymentKey", orderId = "orderId", amount = 10000, - paymentType = PaymentType.NORMAL ) val cancelRequest: PaymentCancelRequest = PaymentCancelRequest( @@ -286,10 +285,13 @@ object PaymentFixture { method: PaymentMethod, cardDetail: CardDetail?, easyPayDetail: EasyPayDetail?, - transferDetail: TransferDetail? + transferDetail: TransferDetail?, + orderId: String = randomString(25), ) = PaymentClientConfirmResponse( paymentKey = paymentKey, status = PaymentStatus.DONE, + orderId = orderId, + type = PaymentType.NORMAL, totalAmount = amount, vat = (amount * 0.1).toInt(), suppliedAmount = (amount * 0.9).toInt(), -- 2.47.2 From dd406505ec2733b8bf905748de1694b1375af642 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 16:31:27 +0900 Subject: [PATCH 11/45] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20API=20=EC=97=90=EB=9F=AC=20=EC=A4=91,=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=A0=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=84=EB=8F=84=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/domain/PaymentClientError.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentClientError.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentClientError.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentClientError.kt new file mode 100644 index 00000000..fb064ad1 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentClientError.kt @@ -0,0 +1,42 @@ +package com.sangdol.roomescape.payment.business.domain + +enum class PaymentClientError { + ALREADY_PROCESSED_PAYMENT, + EXCEED_MAX_CARD_INSTALLMENT_PLAN, + NOT_ALLOWED_POINT_USE, + INVALID_REJECT_CARD, + BELOW_MINIMUM_AMOUNT, + INVALID_CARD_EXPIRATION, + INVALID_STOPPED_CARD, + EXCEED_MAX_DAILY_PAYMENT_COUNT, + NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT, + INVALID_CARD_INSTALLMENT_PLAN, + NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN, + EXCEED_MAX_PAYMENT_AMOUNT, + INVALID_CARD_LOST_OR_STOLEN, + RESTRICTED_TRANSFER_ACCOUNT, + INVALID_CARD_NUMBER, + EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT, + EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT, + CARD_PROCESSING_ERROR, + EXCEED_MAX_AMOUNT, + INVALID_ACCOUNT_INFO_RE_REGISTER, + NOT_AVAILABLE_PAYMENT, + EXCEED_MAX_MONTHLY_PAYMENT_AMOUNT, + REJECT_ACCOUNT_PAYMENT, + REJECT_CARD_PAYMENT, + REJECT_CARD_COMPANY, + FORBIDDEN_REQUEST, + EXCEED_MAX_AUTH_COUNT, + EXCEED_MAX_ONE_DAY_AMOUNT, + NOT_AVAILABLE_BANK, + INVALID_PASSWORD, + FDS_ERROR, + ; + + companion object { + fun contains(code: String): Boolean { + return entries.any { it.name == code } + } + } +} -- 2.47.2 From ef64a740c2dabdb8dc288e0eecd3da53d9b96918 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 16:31:40 +0900 Subject: [PATCH 12/45] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=EC=9D=98?= =?UTF-8?q?=20=EC=99=B8=EB=B6=80=20API=20=ED=98=B8=EC=B6=9C=20+=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=A0=95=EB=B3=B4=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EA=B0=81?= =?UTF-8?q?=EA=B0=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/business/PaymentService.kt | 46 ++++++++++++------- .../roomescape/payment/docs/PaymentAPI.kt | 6 +-- .../payment/web/PaymentController.kt | 8 ++-- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index 654346f9..5bbea39f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -1,6 +1,8 @@ package com.sangdol.roomescape.payment.business import com.sangdol.common.persistence.TransactionExecutionUtil +import com.sangdol.roomescape.payment.business.domain.PaymentClientError +import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse @@ -24,27 +26,39 @@ class PaymentService( private val paymentWriter: PaymentWriter, private val transactionExecutionUtil: TransactionExecutionUtil, ) { - fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse { - val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm( - paymentKey = request.paymentKey, - orderId = request.orderId, - amount = request.amount, - ) + fun requestConfirm(request: PaymentConfirmRequest): PaymentClientConfirmResponse { + try { + return paymentClient.confirm(request.paymentKey, request.orderId, request.amount) + } catch (e: ExternalPaymentException) { + val errorCode = if (e.httpStatusCode in 400..<500) { + PaymentErrorCode.PAYMENT_CLIENT_ERROR + } else { + PaymentErrorCode.PAYMENT_PROVIDER_ERROR + } - return transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - val payment: PaymentEntity = paymentWriter.createPayment( - reservationId = reservationId, - paymentClientConfirmResponse = clientConfirmResponse - ) - val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id) + val message = if (PaymentClientError.contains(e.errorCode)) { + "${errorCode.message}(${e.message})" + } else { + errorCode.message + } - PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) - } ?: run { - log.warn { "[confirm] 결제 확정 중 예상치 못한 null 반환" } - throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + throw PaymentException(errorCode, message) } } + fun savePayment( + reservationId: Long, + paymentConfirmResponse: PaymentClientConfirmResponse + ): PaymentCreateResponse { + val payment: PaymentEntity = paymentWriter.createPayment( + reservationId = reservationId, + paymentClientConfirmResponse = paymentConfirmResponse + ) + val detail: PaymentDetailEntity = paymentWriter.createDetail(paymentConfirmResponse, payment.id) + + return PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) + } + fun cancel(userId: Long, request: PaymentCancelRequest) { val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt index 171df140..c79b1cb7 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt @@ -4,16 +4,15 @@ import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.common.types.CurrentUserContext +import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse import com.sangdol.roomescape.payment.web.PaymentCancelRequest import com.sangdol.roomescape.payment.web.PaymentConfirmRequest -import com.sangdol.roomescape.payment.web.PaymentCreateResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestParam interface PaymentAPI { @@ -21,9 +20,8 @@ interface PaymentAPI { @Operation(summary = "결제 승인") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) fun confirmPayment( - @RequestParam(required = true) reservationId: Long, @Valid @RequestBody request: PaymentConfirmRequest - ): ResponseEntity> + ): ResponseEntity> @Operation(summary = "결제 취소") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt index f4830f20..e4c1e6f1 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt @@ -5,6 +5,7 @@ import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.docs.PaymentAPI +import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -15,12 +16,11 @@ class PaymentController( private val paymentService: PaymentService ) : PaymentAPI { - @PostMapping + @PostMapping("/confirm") override fun confirmPayment( - @RequestParam(required = true) reservationId: Long, @Valid @RequestBody request: PaymentConfirmRequest - ): ResponseEntity> { - val response = paymentService.confirm(reservationId, request) + ): ResponseEntity> { + val response = paymentService.requestConfirm(request) return ResponseEntity.ok(CommonApiResponse(response)) } -- 2.47.2 From 038381424cf73dd1d661c5e018340bad1240993f Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 17:03:49 +0900 Subject: [PATCH 13/45] =?UTF-8?q?refactor:=20user=20=EB=82=B4=20DTO,=20Map?= =?UTF-8?q?per=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationService.kt | 3 +-- .../reservation/web/ReservationDto.kt | 2 +- .../roomescape/user/business/UserService.kt | 12 +++++----- .../sangdol/roomescape/user/docs/UserAPI.kt | 6 ++--- .../{web/UserDTO.kt => dto/UserCreateDTO.kt} | 18 +-------------- .../roomescape/user/dto/UserFindDTO.kt | 8 +++++++ .../user/{business => }/dto/UserLoginDTO.kt | 11 ++-------- .../user/mapper/UserMappingExtensions.kt | 22 +++++++++++++++++++ .../roomescape/user/web/UserController.kt | 3 +++ .../sangdol/data/DefaultDataInitializer.kt | 2 +- .../sangdol/roomescape/supports/Fixtures.kt | 4 ++-- .../roomescape/supports/TestAuthUtil.kt | 2 +- .../sangdol/roomescape/user/UserApiTest.kt | 4 ++-- 13 files changed, 53 insertions(+), 44 deletions(-) rename service/src/main/kotlin/com/sangdol/roomescape/user/{web/UserDTO.kt => dto/UserCreateDTO.kt} (65%) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserFindDTO.kt rename service/src/main/kotlin/com/sangdol/roomescape/user/{business => }/dto/UserLoginDTO.kt (65%) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/user/mapper/UserMappingExtensions.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index 00ef7fa7..48a680f6 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -13,7 +13,7 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.user.business.UserService -import com.sangdol.roomescape.user.web.UserContactResponse +import com.sangdol.roomescape.user.dto.UserContactResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull @@ -159,5 +159,4 @@ class ReservationService( } } - } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt index 6a950ff6..c932910d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt @@ -4,7 +4,7 @@ import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse -import com.sangdol.roomescape.user.web.UserContactResponse +import com.sangdol.roomescape.user.dto.UserContactResponse import jakarta.validation.constraints.NotEmpty import java.time.Instant import java.time.LocalDate diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/business/UserService.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/business/UserService.kt index e84a49ce..ecf8c7db 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/business/UserService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/business/UserService.kt @@ -2,15 +2,15 @@ package com.sangdol.roomescape.user.business import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.common.types.CurrentUserContext -import com.sangdol.roomescape.user.business.dto.UserLoginCredentials -import com.sangdol.roomescape.user.business.dto.toCredentials +import com.sangdol.roomescape.user.dto.UserContactResponse +import com.sangdol.roomescape.user.dto.UserCreateRequest +import com.sangdol.roomescape.user.dto.UserCreateResponse +import com.sangdol.roomescape.user.dto.UserLoginCredentials import com.sangdol.roomescape.user.exception.UserErrorCode import com.sangdol.roomescape.user.exception.UserException import com.sangdol.roomescape.user.infrastructure.persistence.* -import com.sangdol.roomescape.user.web.UserContactResponse -import com.sangdol.roomescape.user.web.UserCreateRequest -import com.sangdol.roomescape.user.web.UserCreateResponse -import com.sangdol.roomescape.user.web.toEntity +import com.sangdol.roomescape.user.mapper.toCredentials +import com.sangdol.roomescape.user.mapper.toEntity import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/docs/UserAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/docs/UserAPI.kt index 6e3d116d..4c1069cb 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/docs/UserAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/docs/UserAPI.kt @@ -4,9 +4,9 @@ import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.common.types.CurrentUserContext -import com.sangdol.roomescape.user.web.UserContactResponse -import com.sangdol.roomescape.user.web.UserCreateRequest -import com.sangdol.roomescape.user.web.UserCreateResponse +import com.sangdol.roomescape.user.dto.UserContactResponse +import com.sangdol.roomescape.user.dto.UserCreateRequest +import com.sangdol.roomescape.user.dto.UserCreateResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/web/UserDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserCreateDTO.kt similarity index 65% rename from service/src/main/kotlin/com/sangdol/roomescape/user/web/UserDTO.kt rename to service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserCreateDTO.kt index d77e3ba0..cbeed230 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/web/UserDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserCreateDTO.kt @@ -1,4 +1,4 @@ -package com.sangdol.roomescape.user.web +package com.sangdol.roomescape.user.dto import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus @@ -27,23 +27,7 @@ data class UserCreateRequest( val regionCode: String?, ) -fun UserCreateRequest.toEntity(id: Long, status: UserStatus) = UserEntity( - id = id, - name = this.name, - email = this.email, - password = this.password, - phone = this.phone, - regionCode = this.regionCode, - status = status -) - data class UserCreateResponse( val id: Long, val name: String ) - -data class UserContactResponse( - val id: Long, - val name: String, - val phone: String -) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserFindDTO.kt new file mode 100644 index 00000000..27550966 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserFindDTO.kt @@ -0,0 +1,8 @@ +package com.sangdol.roomescape.user.dto + +data class UserContactResponse( + val id: Long, + val name: String, + val phone: String +) + diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/business/dto/UserLoginDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserLoginDTO.kt similarity index 65% rename from service/src/main/kotlin/com/sangdol/roomescape/user/business/dto/UserLoginDTO.kt rename to service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserLoginDTO.kt index deb3dc6e..241616ef 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/business/dto/UserLoginDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserLoginDTO.kt @@ -1,8 +1,7 @@ -package com.sangdol.roomescape.user.business.dto +package com.sangdol.roomescape.user.dto import com.sangdol.roomescape.auth.web.LoginCredentials import com.sangdol.roomescape.auth.web.LoginSuccessResponse -import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity data class UserLoginCredentials( override val id: Long, @@ -15,13 +14,7 @@ data class UserLoginCredentials( ) } -fun UserEntity.toCredentials() = UserLoginCredentials( - id = this.id, - password = this.password, - name = this.name, -) - data class UserLoginSuccessResponse( override val accessToken: String, override val name: String, -) : LoginSuccessResponse() \ No newline at end of file +) : LoginSuccessResponse() diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/mapper/UserMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/mapper/UserMappingExtensions.kt new file mode 100644 index 00000000..5a43d7cd --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/mapper/UserMappingExtensions.kt @@ -0,0 +1,22 @@ +package com.sangdol.roomescape.user.mapper + +import com.sangdol.roomescape.user.dto.UserCreateRequest +import com.sangdol.roomescape.user.dto.UserLoginCredentials +import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus + +fun UserCreateRequest.toEntity(id: Long, status: UserStatus) = UserEntity( + id = id, + name = this.name, + email = this.email, + password = this.password, + phone = this.phone, + regionCode = this.regionCode, + status = status +) + +fun UserEntity.toCredentials() = UserLoginCredentials( + id = this.id, + password = this.password, + name = this.name, +) \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/web/UserController.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/web/UserController.kt index 097830ee..ecbaa665 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/web/UserController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/web/UserController.kt @@ -5,6 +5,9 @@ import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.docs.UserAPI +import com.sangdol.roomescape.user.dto.UserContactResponse +import com.sangdol.roomescape.user.dto.UserCreateRequest +import com.sangdol.roomescape.user.dto.UserCreateResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* diff --git a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt index 6ba28855..e4b1f55c 100644 --- a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt @@ -19,7 +19,7 @@ import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.user.business.SIGNUP import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus -import com.sangdol.roomescape.user.web.UserContactResponse +import com.sangdol.roomescape.user.dto.UserContactResponse import io.kotest.core.test.TestCaseOrder import jakarta.persistence.EntityManager import kotlinx.coroutines.* diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 750d94a3..2d0db551 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -22,8 +22,8 @@ import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.web.ThemeCreateRequest import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus -import com.sangdol.roomescape.user.web.MIN_PASSWORD_LENGTH -import com.sangdol.roomescape.user.web.UserCreateRequest +import com.sangdol.roomescape.user.dto.MIN_PASSWORD_LENGTH +import com.sangdol.roomescape.user.dto.UserCreateRequest import java.time.LocalDate import java.time.LocalTime diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt index 90f111c4..766f2c2d 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt @@ -9,7 +9,7 @@ import com.sangdol.roomescape.auth.web.PrincipalType import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository -import com.sangdol.roomescape.user.web.UserCreateRequest +import com.sangdol.roomescape.user.dto.UserCreateRequest import io.restassured.module.kotlin.extensions.Extract import io.restassured.module.kotlin.extensions.Given import io.restassured.module.kotlin.extensions.Then diff --git a/service/src/test/kotlin/com/sangdol/roomescape/user/UserApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/user/UserApiTest.kt index 108152a6..5366c840 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/user/UserApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/user/UserApiTest.kt @@ -12,8 +12,8 @@ import com.sangdol.roomescape.supports.runTest import com.sangdol.roomescape.user.business.SIGNUP import com.sangdol.roomescape.user.exception.UserErrorCode import com.sangdol.roomescape.user.infrastructure.persistence.* -import com.sangdol.roomescape.user.web.MIN_PASSWORD_LENGTH -import com.sangdol.roomescape.user.web.UserCreateRequest +import com.sangdol.roomescape.user.dto.MIN_PASSWORD_LENGTH +import com.sangdol.roomescape.user.dto.UserCreateRequest import io.kotest.assertions.assertSoftly import io.kotest.matchers.shouldBe import io.mockk.every -- 2.47.2 From c4604ccdde8108097889e890c2cd4548bbb30b1a Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 17:31:13 +0900 Subject: [PATCH 14/45] =?UTF-8?q?refactor:=20theme=20=EB=82=B4=20DTO,=20Ma?= =?UTF-8?q?pper=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationValidator.kt | 2 +- .../theme/business/AdminThemeService.kt | 136 ++++++++++++++++ .../roomescape/theme/business/DateUtils.kt | 11 -- .../roomescape/theme/business/ThemeService.kt | 150 +++--------------- .../theme/business/ThemeValidator.kt | 4 +- .../sangdol/roomescape/theme/docs/ThemeApi.kt | 15 +- .../roomescape/theme/dto/AdminThemeFindDTO.kt | 31 ++++ .../theme/dto/AdminThemeWriteDTO.kt | 49 ++++++ .../roomescape/theme/dto/UserThemeFindDTO.kt | 19 +++ .../mapper/AdminThemeMappingExtensions.kt | 48 ++++++ .../UserThemeMappingExtensions.kt} | 28 +--- .../theme/web/AdminThemeController.kt | 23 +-- .../roomescape/theme/web/AdminThemeDto.kt | 127 --------------- .../roomescape/theme/web/ThemeController.kt | 2 + .../roomescape/supports/DummyInitializer.kt | 4 +- .../sangdol/roomescape/supports/Fixtures.kt | 2 +- .../roomescape/theme/AdminThemeApiTest.kt | 2 +- .../sangdol/roomescape/theme/ThemeApiTest.kt | 4 +- 18 files changed, 344 insertions(+), 313 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/theme/business/DateUtils.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeFindDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeWriteDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/theme/dto/UserThemeFindDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/theme/mapper/AdminThemeMappingExtensions.kt rename service/src/main/kotlin/com/sangdol/roomescape/theme/{web/ThemeDto.kt => mapper/UserThemeMappingExtensions.kt} (65%) delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeDto.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index fb410b13..2232613e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -7,7 +7,7 @@ import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.web.ScheduleSummaryResponse -import com.sangdol.roomescape.theme.web.ThemeInfoResponse +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt new file mode 100644 index 00000000..43c04df7 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/AdminThemeService.kt @@ -0,0 +1,136 @@ +package com.sangdol.roomescape.theme.business + +import com.sangdol.common.persistence.IDGenerator +import com.sangdol.roomescape.admin.business.AdminService +import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.theme.dto.ThemeDetailResponse +import com.sangdol.roomescape.theme.dto.ThemeSummaryListResponse +import com.sangdol.roomescape.theme.dto.ThemeNameListResponse +import com.sangdol.roomescape.theme.dto.ThemeCreateRequest +import com.sangdol.roomescape.theme.dto.ThemeCreateResponse +import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest +import com.sangdol.roomescape.theme.exception.ThemeErrorCode +import com.sangdol.roomescape.theme.exception.ThemeException +import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity +import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository +import com.sangdol.roomescape.theme.mapper.toDetailResponse +import com.sangdol.roomescape.theme.mapper.toSummaryListResponse +import com.sangdol.roomescape.theme.mapper.toEntity +import com.sangdol.roomescape.theme.mapper.toNameListResponse +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class AdminThemeService( + private val themeRepository: ThemeRepository, + private val themeValidator: ThemeValidator, + private val idGenerator: IDGenerator, + private val adminService: AdminService +) { + @Transactional(readOnly = true) + fun findThemeSummaries(): ThemeSummaryListResponse { + log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } + + return themeRepository.findAll() + .toSummaryListResponse() + .also { log.info { "[findAdminThemes] ${it.themes.size}개 테마 조회 완료" } } + } + + @Transactional(readOnly = true) + fun findThemeDetail(id: Long): ThemeDetailResponse { + log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" } + + val theme: ThemeEntity = findOrThrow(id) + + val createdBy = adminService.findOperatorOrUnknown(theme.createdBy) + val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy) + val audit = AuditingInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy) + + return theme.toDetailResponse(audit) + .also { log.info { "[findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } + } + + @Transactional(readOnly = true) + fun findActiveThemes(): ThemeNameListResponse { + log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" } + + return themeRepository.findActiveThemes() + .toNameListResponse() + .also { + log.info { "[findActiveThemes] ${it.themes.size}개 테마 조회 완료" } + } + } + + + @Transactional + fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { + log.info { "[createTheme] 테마 생성 시작: name=${request.name}" } + + themeValidator.validateCanCreate(request) + + val theme: ThemeEntity = request.toEntity(id = idGenerator.create()) + .also { themeRepository.save(it) } + + return ThemeCreateResponse(theme.id).also { + log.info { "[createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" } + } + } + + + @Transactional + fun deleteTheme(id: Long) { + log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" } + + val theme: ThemeEntity = findOrThrow(id) + + themeRepository.delete(theme).also { + log.info { "[deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" } + } + } + + @Transactional + fun updateTheme(id: Long, request: ThemeUpdateRequest) { + log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" } + + if (request.isAllParamsNull()) { + log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" } + return + } + + themeValidator.validateCanUpdate(request) + + val theme: ThemeEntity = findOrThrow(id) + + theme.modifyIfNotNull( + request.name, + request.description, + request.thumbnailUrl, + request.difficulty, + request.price, + request.minParticipants, + request.maxParticipants, + request.availableMinutes, + request.expectedMinutesFrom, + request.expectedMinutesTo, + request.isActive, + ).also { + log.info { "[updateTheme] 테마 수정 완료: id=$id, request=${request}" } + } + } + + private fun findOrThrow(id: Long): ThemeEntity { + log.info { "[findOrThrow] 테마 조회 시작: id=$id" } + + return themeRepository.findByIdOrNull(id) + ?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } } + ?: run { + log.warn { "[findOrThrow] 테마 조회 실패: id=$id" } + throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + } + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/DateUtils.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/DateUtils.kt deleted file mode 100644 index 1a0ba300..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/DateUtils.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.sangdol.roomescape.theme.business - -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.temporal.TemporalAdjusters - -object DateUtils { - fun getSundayOfPreviousWeek(date: LocalDate): LocalDate = date - .minusWeeks(1) - .with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt index 78788a17..50e38130 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeService.kt @@ -1,44 +1,38 @@ package com.sangdol.roomescape.theme.business -import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.utils.KoreaDate -import com.sangdol.roomescape.admin.business.AdminService -import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeException -import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.theme.web.* +import com.sangdol.roomescape.theme.mapper.toInfoResponse +import com.sangdol.roomescape.theme.mapper.toListResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters private val log: KLogger = KotlinLogging.logger {} -/** - * Structure: - * - Public: 모두가 접근 가능한 메서드 - * - Store Admin: 매장 관리자가 사용하는 메서드 - * - HQ Admin: 본사 관리자가 사용하는 메서드 - * - Common: 공통 메서드 - */ @Service class ThemeService( - private val themeRepository: ThemeRepository, - private val themeValidator: ThemeValidator, - private val idGenerator: IDGenerator, - private val adminService: AdminService + private val themeRepository: ThemeRepository ) { - // ======================================== - // Public (인증 불필요) - // ======================================== @Transactional(readOnly = true) fun findInfoById(id: Long): ThemeInfoResponse { log.info { "[findInfoById] 테마 조회 시작: id=$id" } - return findOrThrow(id).toInfoResponse() + val theme = themeRepository.findByIdOrNull(id) ?: run { + log.warn { "[updateTheme] 테마 조회 실패: id=$id" } + throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + } + + return theme.toInfoResponse() .also { log.info { "[findInfoById] 테마 조회 완료: id=$id" } } } @@ -54,115 +48,11 @@ class ThemeService( .also { log.info { "[findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" } } - - } - - // ======================================== - // HQ Admin (본사) - // ======================================== - @Transactional(readOnly = true) - fun findAdminThemes(): AdminThemeSummaryListResponse { - log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } - - return themeRepository.findAll() - .toAdminThemeSummaryListResponse() - .also { log.info { "[findAdminThemes] ${it.themes.size}개 테마 조회 완료" } } - } - - @Transactional(readOnly = true) - fun findAdminThemeDetail(id: Long): AdminThemeDetailResponse { - log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" } - - val theme: ThemeEntity = findOrThrow(id) - - val createdBy = adminService.findOperatorOrUnknown(theme.createdBy) - val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy) - val audit = AuditingInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy) - - return theme.toAdminThemeDetailResponse(audit) - .also { log.info { "[findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } - } - - @Transactional - fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { - log.info { "[createTheme] 테마 생성 시작: name=${request.name}" } - - themeValidator.validateCanCreate(request) - - val theme: ThemeEntity = request.toEntity(id = idGenerator.create()) - .also { themeRepository.save(it) } - - return ThemeCreateResponse(theme.id).also { - log.info { "[createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" } - } - } - - @Transactional - fun deleteTheme(id: Long) { - log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" } - - val theme: ThemeEntity = findOrThrow(id) - - themeRepository.delete(theme).also { - log.info { "[deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" } - } - } - - @Transactional - fun updateTheme(id: Long, request: ThemeUpdateRequest) { - log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" } - - if (request.isAllParamsNull()) { - log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" } - return - } - - themeValidator.validateCanUpdate(request) - - val theme: ThemeEntity = findOrThrow(id) - - theme.modifyIfNotNull( - request.name, - request.description, - request.thumbnailUrl, - request.difficulty, - request.price, - request.minParticipants, - request.maxParticipants, - request.availableMinutes, - request.expectedMinutesFrom, - request.expectedMinutesTo, - request.isActive, - ).also { - log.info { "[updateTheme] 테마 수정 완료: id=$id, request=${request}" } - } - } - - // ======================================== - // Store Admin (매장) - // ======================================== - @Transactional(readOnly = true) - fun findActiveThemes(): SimpleActiveThemeListResponse { - log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" } - - return themeRepository.findActiveThemes() - .toSimpleActiveThemeResponse() - .also { - log.info { "[findActiveThemes] ${it.themes.size}개 테마 조회 완료" } - } - } - - // ======================================== - // Common (공통 메서드) - // ======================================== - private fun findOrThrow(id: Long): ThemeEntity { - log.info { "[findOrThrow] 테마 조회 시작: id=$id" } - - return themeRepository.findByIdOrNull(id) - ?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } } - ?: run { - log.warn { "[updateTheme] 테마 조회 실패: id=$id" } - throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) - } } } + +object DateUtils { + fun getSundayOfPreviousWeek(date: LocalDate): LocalDate = date + .minusWeeks(1) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeValidator.kt index e8a362f4..8be522f6 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/business/ThemeValidator.kt @@ -3,8 +3,8 @@ package com.sangdol.roomescape.theme.business import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeException import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.theme.web.ThemeCreateRequest -import com.sangdol.roomescape.theme.web.ThemeUpdateRequest +import com.sangdol.roomescape.theme.dto.ThemeCreateRequest +import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/docs/ThemeApi.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/docs/ThemeApi.kt index 14acfd0c..40abab51 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/docs/ThemeApi.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/docs/ThemeApi.kt @@ -5,7 +5,14 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.Public -import com.sangdol.roomescape.theme.web.* +import com.sangdol.roomescape.theme.dto.ThemeDetailResponse +import com.sangdol.roomescape.theme.dto.ThemeSummaryListResponse +import com.sangdol.roomescape.theme.dto.ThemeNameListResponse +import com.sangdol.roomescape.theme.dto.ThemeCreateRequest +import com.sangdol.roomescape.theme.dto.ThemeCreateResponse +import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse +import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses @@ -20,12 +27,12 @@ interface AdminThemeAPI { @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) - fun getAdminThemeSummaries(): ResponseEntity> + fun getAdminThemeSummaries(): ResponseEntity> @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) - fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity> + fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity> @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) @Operation(summary = "테마 추가") @@ -48,7 +55,7 @@ interface AdminThemeAPI { @AdminOnly(privilege = Privilege.READ_SUMMARY) @Operation(summary = "현재 활성화 상태인 테마 ID + 이름 목록 조회") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) - fun getActiveThemes(): ResponseEntity> + fun getActiveThemes(): ResponseEntity> } interface PublicThemeAPI { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeFindDTO.kt new file mode 100644 index 00000000..641ee5ba --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeFindDTO.kt @@ -0,0 +1,31 @@ +package com.sangdol.roomescape.theme.dto + +import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty + +data class ThemeSummaryResponse( + val id: Long, + val name: String, + val difficulty: Difficulty, + val price: Int, + val isActive: Boolean +) + +data class ThemeSummaryListResponse( + val themes: List +) + +data class ThemeDetailResponse( + val theme: ThemeInfoResponse, + val isActive: Boolean, + val audit: AuditingInfo +) + +data class ThemeNameResponse( + val id: Long, + val name: String +) + +data class ThemeNameListResponse( + val themes: List +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeWriteDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeWriteDTO.kt new file mode 100644 index 00000000..8adc3c31 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/AdminThemeWriteDTO.kt @@ -0,0 +1,49 @@ +package com.sangdol.roomescape.theme.dto + +import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty + +data class ThemeCreateRequest( + val name: String, + val description: String, + val thumbnailUrl: String, + val difficulty: Difficulty, + val price: Int, + val minParticipants: Short, + val maxParticipants: Short, + val availableMinutes: Short, + val expectedMinutesFrom: Short, + val expectedMinutesTo: Short, + val isActive: Boolean +) + +data class ThemeCreateResponse( + val id: Long +) + +data class ThemeUpdateRequest( + val name: String? = null, + val description: String? = null, + val thumbnailUrl: String? = null, + val difficulty: Difficulty? = null, + val price: Int? = null, + val minParticipants: Short? = null, + val maxParticipants: Short? = null, + val availableMinutes: Short? = null, + val expectedMinutesFrom: Short? = null, + val expectedMinutesTo: Short? = null, + val isActive: Boolean? = null, +) { + fun isAllParamsNull(): Boolean { + return name == null && + description == null && + thumbnailUrl == null && + difficulty == null && + price == null && + minParticipants == null && + maxParticipants == null && + availableMinutes == null && + expectedMinutesFrom == null && + expectedMinutesTo == null && + isActive == null + } +} \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/UserThemeFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/UserThemeFindDTO.kt new file mode 100644 index 00000000..40a36ae1 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/UserThemeFindDTO.kt @@ -0,0 +1,19 @@ +package com.sangdol.roomescape.theme.dto + +data class ThemeInfoResponse( + val id: Long, + val name: String, + val thumbnailUrl: String, + val description: String, + val difficulty: String, + val price: Int, + val minParticipants: Short, + val maxParticipants: Short, + val availableMinutes: Short, + val expectedMinutesFrom: Short, + val expectedMinutesTo: Short +) + +data class ThemeInfoListResponse( + val themes: List +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/mapper/AdminThemeMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/mapper/AdminThemeMappingExtensions.kt new file mode 100644 index 00000000..dfe91ccc --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/mapper/AdminThemeMappingExtensions.kt @@ -0,0 +1,48 @@ +package com.sangdol.roomescape.theme.mapper + +import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.theme.dto.* +import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity + +fun ThemeCreateRequest.toEntity(id: Long) = ThemeEntity( + id = id, + name = this.name, + description = this.description, + thumbnailUrl = this.thumbnailUrl, + difficulty = this.difficulty, + price = this.price, + minParticipants = this.minParticipants, + maxParticipants = this.maxParticipants, + availableMinutes = this.availableMinutes, + expectedMinutesFrom = this.expectedMinutesFrom, + expectedMinutesTo = this.expectedMinutesTo, + isActive = this.isActive +) + +fun ThemeEntity.toSummaryResponse() = ThemeSummaryResponse( + id = this.id, + name = this.name, + difficulty = this.difficulty, + price = this.price, + isActive = this.isActive +) + +fun ThemeEntity.toDetailResponse(audit: AuditingInfo) = + ThemeDetailResponse( + theme = this.toInfoResponse(), + isActive = this.isActive, + audit = audit + ) + +fun ThemeEntity.toNameResponse() = ThemeNameResponse( + id = this.id, + name = this.name +) + +fun List.toSummaryListResponse() = ThemeSummaryListResponse( + themes = this.map { it.toSummaryResponse() } +) + +fun List.toNameListResponse() = ThemeNameListResponse( + themes = this.map { it.toNameResponse() } +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/ThemeDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/mapper/UserThemeMappingExtensions.kt similarity index 65% rename from service/src/main/kotlin/com/sangdol/roomescape/theme/web/ThemeDto.kt rename to service/src/main/kotlin/com/sangdol/roomescape/theme/mapper/UserThemeMappingExtensions.kt index 46463d38..9c0a346b 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/ThemeDto.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/mapper/UserThemeMappingExtensions.kt @@ -1,23 +1,11 @@ -package com.sangdol.roomescape.theme.web +package com.sangdol.roomescape.theme.mapper import com.sangdol.roomescape.theme.business.domain.ThemeInfo +import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity -data class ThemeInfoResponse( - val id: Long, - val name: String, - val thumbnailUrl: String, - val description: String, - val difficulty: String, - val price: Int, - val minParticipants: Short, - val maxParticipants: Short, - val availableMinutes: Short, - val expectedMinutesFrom: Short, - val expectedMinutesTo: Short -) - -fun ThemeInfo.toInfoResponse() = ThemeInfoResponse( +fun ThemeInfo.toResponse() = ThemeInfoResponse( id = this.id, name = this.name, thumbnailUrl = this.thumbnailUrl, @@ -45,10 +33,8 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse( expectedMinutesTo = this.expectedMinutesTo ) -data class ThemeInfoListResponse( - val themes: List +fun List.toListResponse() = ThemeInfoListResponse( + themes = this.map { it.toResponse() } ) -fun List.toListResponse() = ThemeInfoListResponse( - themes = this.map { it.toInfoResponse() } -) + diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeController.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeController.kt index 3a5f6375..e0d86291 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeController.kt @@ -1,8 +1,9 @@ package com.sangdol.roomescape.theme.web import com.sangdol.common.types.web.CommonApiResponse -import com.sangdol.roomescape.theme.business.ThemeService +import com.sangdol.roomescape.theme.business.AdminThemeService import com.sangdol.roomescape.theme.docs.AdminThemeAPI +import com.sangdol.roomescape.theme.dto.* import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -10,19 +11,19 @@ import java.net.URI @RestController class AdminThemeController( - private val themeService: ThemeService, + private val adminThemeService: AdminThemeService, ) : AdminThemeAPI { @GetMapping("/admin/themes") - override fun getAdminThemeSummaries(): ResponseEntity> { - val response = themeService.findAdminThemes() + override fun getAdminThemeSummaries(): ResponseEntity> { + val response = adminThemeService.findThemeSummaries() return ResponseEntity.ok(CommonApiResponse(response)) } @GetMapping("/admin/themes/{id}") - override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity> { - val response = themeService.findAdminThemeDetail(id) + override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity> { + val response = adminThemeService.findThemeDetail(id) return ResponseEntity.ok(CommonApiResponse(response)) } @@ -31,7 +32,7 @@ class AdminThemeController( override fun createTheme( @Valid @RequestBody themeCreateRequest: ThemeCreateRequest ): ResponseEntity> { - val response = themeService.createTheme(themeCreateRequest) + val response = adminThemeService.createTheme(themeCreateRequest) return ResponseEntity.created(URI.create("/admin/themes/${response.id}")) .body(CommonApiResponse(response)) @@ -39,7 +40,7 @@ class AdminThemeController( @DeleteMapping("/admin/themes/{id}") override fun deleteTheme(@PathVariable id: Long): ResponseEntity> { - themeService.deleteTheme(id) + adminThemeService.deleteTheme(id) return ResponseEntity.noContent().build() } @@ -49,14 +50,14 @@ class AdminThemeController( @PathVariable id: Long, @Valid @RequestBody request: ThemeUpdateRequest ): ResponseEntity> { - themeService.updateTheme(id, request) + adminThemeService.updateTheme(id, request) return ResponseEntity.ok().build() } @GetMapping("/admin/themes/active") - override fun getActiveThemes(): ResponseEntity> { - val response = themeService.findActiveThemes() + override fun getActiveThemes(): ResponseEntity> { + val response = adminThemeService.findActiveThemes() return ResponseEntity.ok(CommonApiResponse(response)) } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeDto.kt deleted file mode 100644 index 94c5a195..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/AdminThemeDto.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.sangdol.roomescape.theme.web - -import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty -import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity - -// ======================================== -// HQ Admin DTO (본사) -// ======================================== -data class ThemeCreateRequest( - val name: String, - val description: String, - val thumbnailUrl: String, - val difficulty: Difficulty, - val price: Int, - val minParticipants: Short, - val maxParticipants: Short, - val availableMinutes: Short, - val expectedMinutesFrom: Short, - val expectedMinutesTo: Short, - val isActive: Boolean -) - -data class ThemeCreateResponse( - val id: Long -) - -fun ThemeCreateRequest.toEntity(id: Long) = ThemeEntity( - id = id, - name = this.name, - description = this.description, - thumbnailUrl = this.thumbnailUrl, - difficulty = this.difficulty, - price = this.price, - minParticipants = this.minParticipants, - maxParticipants = this.maxParticipants, - availableMinutes = this.availableMinutes, - expectedMinutesFrom = this.expectedMinutesFrom, - expectedMinutesTo = this.expectedMinutesTo, - isActive = this.isActive -) - -data class ThemeUpdateRequest( - val name: String? = null, - val description: String? = null, - val thumbnailUrl: String? = null, - val difficulty: Difficulty? = null, - val price: Int? = null, - val minParticipants: Short? = null, - val maxParticipants: Short? = null, - val availableMinutes: Short? = null, - val expectedMinutesFrom: Short? = null, - val expectedMinutesTo: Short? = null, - val isActive: Boolean? = null, -) { - fun isAllParamsNull(): Boolean { - return name == null && - description == null && - thumbnailUrl == null && - difficulty == null && - price == null && - minParticipants == null && - maxParticipants == null && - availableMinutes == null && - expectedMinutesFrom == null && - expectedMinutesTo == null && - isActive == null - } -} - -data class AdminThemeSummaryResponse( - val id: Long, - val name: String, - val difficulty: Difficulty, - val price: Int, - val isActive: Boolean -) - -fun ThemeEntity.toAdminThemeSummaryResponse() = AdminThemeSummaryResponse( - id = this.id, - name = this.name, - difficulty = this.difficulty, - price = this.price, - isActive = this.isActive -) - -data class AdminThemeSummaryListResponse( - val themes: List -) - -fun List.toAdminThemeSummaryListResponse() = AdminThemeSummaryListResponse( - themes = this.map { it.toAdminThemeSummaryResponse() } -) - -data class AdminThemeDetailResponse( - val theme: ThemeInfoResponse, - val isActive: Boolean, - val audit: AuditingInfo -) - -fun ThemeEntity.toAdminThemeDetailResponse(audit: AuditingInfo) = - AdminThemeDetailResponse( - theme = this.toInfoResponse(), - isActive = this.isActive, - audit = audit - ) - -// ======================================== -// Store Admin DTO -// ======================================== -data class SimpleActiveThemeResponse( - val id: Long, - val name: String -) - -fun ThemeEntity.toSimpleActiveThemeResponse() = SimpleActiveThemeResponse( - id = this.id, - name = this.name -) - -data class SimpleActiveThemeListResponse( - val themes: List -) - -fun List.toSimpleActiveThemeResponse() = SimpleActiveThemeListResponse( - themes = this.map { it.toSimpleActiveThemeResponse() } -) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/ThemeController.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/web/ThemeController.kt index f68e341c..f15d3fc7 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/web/ThemeController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/web/ThemeController.kt @@ -3,6 +3,8 @@ package com.sangdol.roomescape.theme.web import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.theme.docs.PublicThemeAPI +import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index d4422ae6..04119355 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -26,8 +26,8 @@ import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.theme.web.ThemeCreateRequest -import com.sangdol.roomescape.theme.web.toEntity +import com.sangdol.roomescape.theme.dto.ThemeCreateRequest +import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import org.springframework.data.repository.findByIdOrNull import java.time.Instant diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 2d0db551..50145d77 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -19,7 +19,7 @@ import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus import com.sangdol.roomescape.store.web.StoreRegisterRequest import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity -import com.sangdol.roomescape.theme.web.ThemeCreateRequest +import com.sangdol.roomescape.theme.dto.ThemeCreateRequest import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus import com.sangdol.roomescape.user.dto.MIN_PASSWORD_LENGTH diff --git a/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt index f70cbecf..d65b60e6 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/theme/AdminThemeApiTest.kt @@ -12,7 +12,7 @@ import com.sangdol.roomescape.theme.business.MIN_PRICE import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.theme.web.ThemeUpdateRequest +import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.hamcrest.CoreMatchers.equalTo diff --git a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt index b99987d4..30e51c65 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt @@ -7,8 +7,8 @@ import com.sangdol.roomescape.theme.business.DateUtils import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.theme.web.ThemeInfoResponse -import com.sangdol.roomescape.theme.web.toEntity +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse +import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.collections.shouldHaveSize -- 2.47.2 From 8bb22a6a840e3fc1919d394b87039da61dc99ade Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 18:04:12 +0900 Subject: [PATCH 15/45] =?UTF-8?q?refactor:=20store=20=EB=82=B4=20DTO,=20Ma?= =?UTF-8?q?pper=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/store/business/StoreService.kt | 12 ++++- .../store/business/StoreValidator.kt | 4 +- .../sangdol/roomescape/store/docs/StoreAPI.kt | 9 +++- .../store/dto/AdminStoreWriteDTO.kt | 22 +++++++++ .../roomescape/store/dto/StoreFindDTO.kt | 31 +++++++++++++ .../store/mapper/StoreMappingExtensions.kt | 34 ++++++++++++++ .../store/web/AdminStoreController.kt | 4 ++ .../roomescape/store/web/AdminStoreDto.kt | 46 ------------------- .../roomescape/store/web/StoreController.kt | 4 +- .../sangdol/roomescape/store/web/StoreDTO.kt | 32 ------------- .../{UserThemeFindDTO.kt => ThemeFindDTO.kt} | 0 .../roomescape/store/AdminStoreApiTest.kt | 2 +- .../sangdol/roomescape/supports/Fixtures.kt | 2 +- 13 files changed, 115 insertions(+), 87 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreDto.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreDTO.kt rename service/src/main/kotlin/com/sangdol/roomescape/theme/dto/{UserThemeFindDTO.kt => ThemeFindDTO.kt} (100%) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt index 4271767f..ab2e4b1d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt @@ -4,12 +4,20 @@ import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.region.business.RegionService +import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreNameListResponse +import com.sangdol.roomescape.store.dto.StoreInfoResponse +import com.sangdol.roomescape.store.dto.StoreRegisterRequest +import com.sangdol.roomescape.store.dto.StoreRegisterResponse +import com.sangdol.roomescape.store.dto.StoreUpdateRequest import com.sangdol.roomescape.store.exception.StoreErrorCode import com.sangdol.roomescape.store.exception.StoreException import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus -import com.sangdol.roomescape.store.web.* +import com.sangdol.roomescape.store.mapper.toDetailResponse +import com.sangdol.roomescape.store.mapper.toInfoResponse +import com.sangdol.roomescape.store.mapper.toSimpleListResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service @@ -85,7 +93,7 @@ class StoreService( } @Transactional(readOnly = true) - fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse { + fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse { log.info { "[getAllActiveStores] 전체 매장 조회 시작" } val regionCode: String? = when { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreValidator.kt index 65d5708e..3a9538e7 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreValidator.kt @@ -3,8 +3,8 @@ package com.sangdol.roomescape.store.business import com.sangdol.roomescape.store.exception.StoreErrorCode import com.sangdol.roomescape.store.exception.StoreException import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository -import com.sangdol.roomescape.store.web.StoreRegisterRequest -import com.sangdol.roomescape.store.web.StoreUpdateRequest +import com.sangdol.roomescape.store.dto.StoreRegisterRequest +import com.sangdol.roomescape.store.dto.StoreUpdateRequest import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt index 20e93eb9..585e6ce6 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt @@ -5,7 +5,12 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.Public -import com.sangdol.roomescape.store.web.* +import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreNameListResponse +import com.sangdol.roomescape.store.dto.StoreInfoResponse +import com.sangdol.roomescape.store.dto.StoreRegisterRequest +import com.sangdol.roomescape.store.dto.StoreRegisterResponse +import com.sangdol.roomescape.store.dto.StoreUpdateRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses @@ -53,7 +58,7 @@ interface PublicStoreAPI { fun getStores( @RequestParam(value = "sido", required = false) sidoCode: String?, @RequestParam(value = "sigungu", required = false) sigunguCode: String? - ): ResponseEntity> + ): ResponseEntity> @Public @Operation(summary = "특정 매장의 정보 조회") diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt new file mode 100644 index 00000000..35b03076 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt @@ -0,0 +1,22 @@ +package com.sangdol.roomescape.store.dto + +import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.region.web.RegionInfoResponse + +data class StoreRegisterRequest( + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val regionCode: String +) + +data class StoreRegisterResponse( + val id: Long +) + +data class StoreUpdateRequest( + val name: String? = null, + val address: String? = null, + val contact: String? = null, +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt new file mode 100644 index 00000000..d2640261 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt @@ -0,0 +1,31 @@ +package com.sangdol.roomescape.store.dto + +import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.region.web.RegionInfoResponse + +data class StoreNameResponse( + val id: Long, + val name: String +) + +data class StoreNameListResponse( + val stores: List +) + +data class StoreInfoResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String +) + +data class DetailStoreResponse( + val id: Long, + val name: String, + val address: String, + val contact: String, + val businessRegNum: String, + val region: RegionInfoResponse, + val audit: AuditingInfo +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt new file mode 100644 index 00000000..2b2f69e6 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt @@ -0,0 +1,34 @@ +package com.sangdol.roomescape.store.mapper + +import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.region.web.RegionInfoResponse +import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreInfoResponse +import com.sangdol.roomescape.store.dto.StoreNameListResponse +import com.sangdol.roomescape.store.dto.StoreNameResponse +import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity + +fun StoreEntity.toInfoResponse() = StoreInfoResponse( + id = this.id, + name = this.name, + address = this.address, + contact = this.contact, + businessRegNum = this.businessRegNum +) + +fun StoreEntity.toDetailResponse( + region: RegionInfoResponse, + audit: AuditingInfo +) = DetailStoreResponse( + id = this.id, + name = this.name, + address = this.address, + contact = this.contact, + businessRegNum = this.businessRegNum, + region = region, + audit = audit, +) + +fun List.toSimpleListResponse() = StoreNameListResponse( + stores = this.map { StoreNameResponse(id = it.id, name = it.name) } +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt index f609043b..2074ae9f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt @@ -3,6 +3,10 @@ package com.sangdol.roomescape.store.web import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.store.business.StoreService import com.sangdol.roomescape.store.docs.AdminStoreAPI +import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreRegisterRequest +import com.sangdol.roomescape.store.dto.StoreRegisterResponse +import com.sangdol.roomescape.store.dto.StoreUpdateRequest import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreDto.kt deleted file mode 100644 index 193e719b..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreDto.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.sangdol.roomescape.store.web - -import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.region.web.RegionInfoResponse -import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity - -data class StoreRegisterRequest( - val name: String, - val address: String, - val contact: String, - val businessRegNum: String, - val regionCode: String -) - -data class StoreRegisterResponse( - val id: Long -) - -data class StoreUpdateRequest( - val name: String? = null, - val address: String? = null, - val contact: String? = null, -) - -data class DetailStoreResponse( - val id: Long, - val name: String, - val address: String, - val contact: String, - val businessRegNum: String, - val region: RegionInfoResponse, - val audit: AuditingInfo -) - -fun StoreEntity.toDetailResponse( - region: RegionInfoResponse, - audit: AuditingInfo -) = DetailStoreResponse( - id = this.id, - name = this.name, - address = this.address, - contact = this.contact, - businessRegNum = this.businessRegNum, - region = region, - audit = audit, -) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreController.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreController.kt index 5afac648..f72cf19e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreController.kt @@ -3,6 +3,8 @@ package com.sangdol.roomescape.store.web import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.store.business.StoreService import com.sangdol.roomescape.store.docs.PublicStoreAPI +import com.sangdol.roomescape.store.dto.StoreNameListResponse +import com.sangdol.roomescape.store.dto.StoreInfoResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -18,7 +20,7 @@ class StoreController( override fun getStores( @RequestParam(value = "sido", required = false) sidoCode: String?, @RequestParam(value = "sigungu", required = false) sigunguCode: String? - ): ResponseEntity> { + ): ResponseEntity> { val response = storeService.getAllActiveStores(sidoCode, sigunguCode) return ResponseEntity.ok(CommonApiResponse(response)) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreDTO.kt deleted file mode 100644 index 0e6e3934..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/web/StoreDTO.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.sangdol.roomescape.store.web - -import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity - -data class SimpleStoreResponse( - val id: Long, - val name: String -) - -data class SimpleStoreListResponse( - val stores: List -) - -fun List.toSimpleListResponse() = SimpleStoreListResponse( - stores = this.map { SimpleStoreResponse(id = it.id, name = it.name) } -) - -data class StoreInfoResponse( - val id: Long, - val name: String, - val address: String, - val contact: String, - val businessRegNum: String -) - -fun StoreEntity.toInfoResponse() = StoreInfoResponse( - id = this.id, - name = this.name, - address = this.address, - contact = this.contact, - businessRegNum = this.businessRegNum -) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/UserThemeFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/dto/ThemeFindDTO.kt similarity index 100% rename from service/src/main/kotlin/com/sangdol/roomescape/theme/dto/UserThemeFindDTO.kt rename to service/src/main/kotlin/com/sangdol/roomescape/theme/dto/ThemeFindDTO.kt diff --git a/service/src/test/kotlin/com/sangdol/roomescape/store/AdminStoreApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/store/AdminStoreApiTest.kt index 9b1f77a8..03418739 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/store/AdminStoreApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/store/AdminStoreApiTest.kt @@ -9,7 +9,7 @@ import com.sangdol.roomescape.store.exception.StoreErrorCode import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus -import com.sangdol.roomescape.store.web.StoreUpdateRequest +import com.sangdol.roomescape.store.dto.StoreUpdateRequest import com.sangdol.roomescape.supports.* import io.kotest.assertions.assertSoftly import io.kotest.matchers.date.shouldBeAfter diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 50145d77..48fcf989 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -16,7 +16,7 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus -import com.sangdol.roomescape.store.web.StoreRegisterRequest +import com.sangdol.roomescape.store.dto.StoreRegisterRequest import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.dto.ThemeCreateRequest -- 2.47.2 From 7bda14984eafe84142ed02acd64f4f531eb55f8e Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 18:05:18 +0900 Subject: [PATCH 16/45] =?UTF-8?q?refactor:=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20ORDER=20B?= =?UTF-8?q?Y=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=20=EC=98=88=EC=95=BD=EC=88=98=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20ID=20=EC=98=A4=EB=A6=84=EC=B0=A8=EC=88=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/ThemeRepository.kt | 3 +- .../sangdol/roomescape/theme/ThemeApiTest.kt | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/theme/infrastructure/persistence/ThemeRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/theme/infrastructure/persistence/ThemeRepository.kt index cef38dff..2260e2fe 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/theme/infrastructure/persistence/ThemeRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/theme/infrastructure/persistence/ThemeRepository.kt @@ -32,10 +32,9 @@ interface ThemeRepository : JpaRepository { AND (s.date BETWEEN :startFrom AND :endAt) GROUP BY s.theme_id - ORDER BY - reservation_count desc LIMIT :count ) ranked_themes ON t.id = ranked_themes.theme_id + ORDER BY ranked_themes.reservation_count DESC, t.id ASC """, nativeQuery = true ) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt index 30e51c65..3051cd56 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt @@ -4,14 +4,15 @@ import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.utils.KoreaDate import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.theme.business.DateUtils +import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.comparables.shouldBeLessThan import org.hamcrest.CoreMatchers.equalTo import org.springframework.http.HttpMethod import java.time.LocalDate @@ -72,6 +73,35 @@ class ThemeApiTest( response.map { it.id }.shouldContainInOrder(expectedResult) } } + + test("예약 수가 동일한 경우 ID 오름차순으로 정렬한다.") { + val expectedSize = initialize("두 개의 테마에 각각 1개의 확정 예약 생성") { + val user = testAuthUtil.defaultUserLogin() + (1..2).map { _ -> + dummyInitializer.createConfirmReservation( + user.first, + scheduleRequest = ScheduleFixture.createRequest.copy( + date = DateUtils.getSundayOfPreviousWeek(KoreaDate.today()) + ) + ) + } + }.size + + runTest( + on = { + get("/themes/most-reserved?count=10") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { res -> + val response: List = + ResponseParser.parseListResponse(res.extract().path("data.themes")) + + response shouldHaveSize expectedSize + response[0].id shouldBeLessThan response[1].id + } + } } } -- 2.47.2 From 5bd6250184892d5322621250c54adbf1210f899f Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 18:25:55 +0900 Subject: [PATCH 17/45] =?UTF-8?q?refactor:=20schedule=20=EB=82=B4=20DTO,?= =?UTF-8?q?=20Mapper=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationValidator.kt | 2 +- .../schedule/business/ScheduleService.kt | 12 ++- .../schedule/business/ScheduleValidator.kt | 4 +- .../roomescape/schedule/docs/ScheduleAPI.kt | 6 +- .../schedule/dto/AdminScheduleFindDTO.kt | 16 ++++ .../schedule/dto/AdminScheduleWriteDTO.kt | 24 ++++++ .../schedule/dto/ScheduleFindDTO.kt | 40 +++++++++ .../mapper/AdminScheduleMappingExtensions.kt | 12 +++ .../mapper/ScheduleMappingExtensions.kt | 42 ++++++++++ .../schedule/web/AdminScheduleController.kt | 4 + .../schedule/web/AdminScheduleDto.kt | 55 ------------- .../schedule/web/ScheduleController.kt | 1 + .../roomescape/schedule/web/ScheduleDto.kt | 81 ------------------- .../schedule/AdminScheduleApiTest.kt | 4 +- .../schedule/ScheduleServiceTest.kt | 4 +- .../roomescape/supports/DummyInitializer.kt | 2 +- .../sangdol/roomescape/supports/Fixtures.kt | 2 +- 17 files changed, 164 insertions(+), 147 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleFindDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleWriteDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleDto.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 2232613e..2574408f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -6,7 +6,7 @@ import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.schedule.web.ScheduleSummaryResponse +import com.sangdol.roomescape.schedule.dto.ScheduleSummaryResponse import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 2dbade7b..9acf630c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -7,13 +7,23 @@ import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.Auditor import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse +import com.sangdol.roomescape.schedule.dto.ScheduleOverviewResponse +import com.sangdol.roomescape.schedule.dto.ScheduleSummaryResponse +import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse +import com.sangdol.roomescape.schedule.mapper.toAdminSummaryListResponse +import com.sangdol.roomescape.schedule.mapper.toOverviewResponse +import com.sangdol.roomescape.schedule.mapper.toResponse +import com.sangdol.roomescape.schedule.mapper.toSummaryResponse import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.schedule.web.* import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt index 5e0f5e1d..75deaa46 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt @@ -6,8 +6,8 @@ import com.sangdol.roomescape.schedule.exception.ScheduleException 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.schedule.web.ScheduleCreateRequest -import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/docs/ScheduleAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/docs/ScheduleAPI.kt index dfbd397e..215f3486 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/docs/ScheduleAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/docs/ScheduleAPI.kt @@ -7,7 +7,11 @@ import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.schedule.web.* +import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse +import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleFindDTO.kt new file mode 100644 index 00000000..afe598cb --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleFindDTO.kt @@ -0,0 +1,16 @@ +package com.sangdol.roomescape.schedule.dto + +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import java.time.LocalTime + +data class AdminScheduleSummaryResponse( + val id: Long, + val themeName: String, + val startFrom: LocalTime, + val endAt: LocalTime, + val status: ScheduleStatus, +) + +data class AdminScheduleSummaryListResponse( + val schedules: List +) \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleWriteDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleWriteDTO.kt new file mode 100644 index 00000000..27701a8b --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/AdminScheduleWriteDTO.kt @@ -0,0 +1,24 @@ +package com.sangdol.roomescape.schedule.dto + +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import java.time.LocalDate +import java.time.LocalTime + +data class ScheduleCreateRequest( + val date: LocalDate, + val time: LocalTime, + val themeId: Long +) + +data class ScheduleCreateResponse( + val id: Long +) + +data class ScheduleUpdateRequest( + val time: LocalTime? = null, + val status: ScheduleStatus? = null +) { + fun isAllParamsNull(): Boolean { + return time == null && status == null + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt new file mode 100644 index 00000000..652a90c3 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt @@ -0,0 +1,40 @@ +package com.sangdol.roomescape.schedule.dto + +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime + +data class ScheduleWithThemeResponse( + val id: Long, + val startFrom: LocalTime, + val endAt: LocalTime, + val themeId: Long, + val themeName: String, + val themeDifficulty: Difficulty, + val status: ScheduleStatus +) + +data class ScheduleWithThemeListResponse( + val schedules: List +) + +data class ScheduleSummaryResponse( + val date: LocalDate, + val time: LocalTime, + val themeId: Long, + val status: ScheduleStatus, + val holdExpiredAt: Instant? = null +) + +data class ScheduleOverviewResponse( + val id: Long, + val storeId: Long, + val storeName: String, + val date: LocalDate, + val startFrom: LocalTime, + val endAt: LocalTime, + val themeId: Long, + val themeName: String, +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt new file mode 100644 index 00000000..4e0b6e50 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt @@ -0,0 +1,12 @@ +package com.sangdol.roomescape.schedule.mapper + +import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryResponse + +fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse( + id = this.id, + themeName = this.themeName, + startFrom = this.time, + endAt = this.getEndAt(), + status = this.status +) \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt new file mode 100644 index 00000000..2482631c --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt @@ -0,0 +1,42 @@ +package com.sangdol.roomescape.schedule.mapper + +import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import com.sangdol.roomescape.schedule.dto.* +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity + +fun ScheduleOverview.toOverviewResponse() = ScheduleOverviewResponse( + id = this.id, + storeId = this.storeId, + storeName = this.storeName, + date = this.date, + startFrom = this.time, + endAt = this.getEndAt(), + themeId = this.themeId, + themeName = this.themeName, +) + +fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse( + date = this.date, + time = this.time, + themeId = this.themeId, + status = this.status, + holdExpiredAt = this.holdExpiredAt +) + +fun ScheduleOverview.toResponse() = ScheduleWithThemeResponse( + id = this.id, + startFrom = this.time, + endAt = this.getEndAt(), + themeId = this.themeId, + themeName = this.themeName, + themeDifficulty = this.themeDifficulty, + status = this.status +) + +fun List.toAdminSummaryListResponse() = AdminScheduleSummaryListResponse( + this.map { it.toAdminSummaryResponse() } +) + +fun List.toResponse() = ScheduleWithThemeListResponse( + this.map { it.toResponse() } +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt index 54a38e6c..444bea51 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt @@ -4,6 +4,10 @@ import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI +import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse +import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest import jakarta.validation.Valid import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.ResponseEntity diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleDto.kt deleted file mode 100644 index fec62160..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleDto.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.sangdol.roomescape.schedule.web - -import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import java.time.LocalDate -import java.time.LocalTime - -// ======================================== -// All-Admin DTO (본사 + 매장) -// ======================================== -data class AdminScheduleSummaryResponse( - val id: Long, - val themeName: String, - val startFrom: LocalTime, - val endAt: LocalTime, - val status: ScheduleStatus, -) - -fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse( - id = this.id, - themeName = this.themeName, - startFrom = this.time, - endAt = this.getEndAt(), - status = this.status -) - -data class AdminScheduleSummaryListResponse( - val schedules: List -) - -fun List.toAdminSummaryListResponse() = AdminScheduleSummaryListResponse( - this.map { it.toAdminSummaryResponse() } -) - -// ======================================== -// Store Admin DTO (매장) -// ======================================== -data class ScheduleCreateRequest( - val date: LocalDate, - val time: LocalTime, - val themeId: Long -) - -data class ScheduleCreateResponse( - val id: Long -) - -data class ScheduleUpdateRequest( - val time: LocalTime? = null, - val status: ScheduleStatus? = null -) { - fun isAllParamsNull(): Boolean { - return time == null && status == null - } -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleController.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleController.kt index 1c5d8c2b..9de0dca5 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleController.kt @@ -4,6 +4,7 @@ import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.docs.PublicScheduleAPI import com.sangdol.roomescape.schedule.docs.UserScheduleAPI +import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt deleted file mode 100644 index 17da9b83..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/ScheduleDto.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.sangdol.roomescape.schedule.web - -import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty -import java.time.Instant -import java.time.LocalDate -import java.time.LocalTime - -// ======================================== -// Public (인증 불필요) -// ======================================== -data class ScheduleWithThemeResponse( - val id: Long, - val startFrom: LocalTime, - val endAt: LocalTime, - val themeId: Long, - val themeName: String, - val themeDifficulty: Difficulty, - val status: ScheduleStatus -) - -fun ScheduleOverview.toResponse() = ScheduleWithThemeResponse( - id = this.id, - startFrom = this.time, - endAt = this.getEndAt(), - themeId = this.themeId, - themeName = this.themeName, - themeDifficulty = this.themeDifficulty, - status = this.status -) - -data class ScheduleWithThemeListResponse( - val schedules: List -) - -fun List.toResponse() = ScheduleWithThemeListResponse( - this.map { it.toResponse() } -) - -// ======================================== -// Other-Service (API 없이 다른 서비스에서 호출) -// ======================================== -data class ScheduleSummaryResponse( - val date: LocalDate, - val time: LocalTime, - val themeId: Long, - val status: ScheduleStatus, - val holdExpiredAt: Instant? = null -) - -fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse( - date = this.date, - time = this.time, - themeId = this.themeId, - status = this.status, - holdExpiredAt = this.holdExpiredAt -) - -data class ScheduleOverviewResponse( - val id: Long, - val storeId: Long, - val storeName: String, - val date: LocalDate, - val startFrom: LocalTime, - val endAt: LocalTime, - val themeId: Long, - val themeName: String, -) - -fun ScheduleOverview.toOverviewResponse() = ScheduleOverviewResponse( - id = this.id, - storeId = this.storeId, - storeName = this.storeName, - date = this.date, - startFrom = this.time, - endAt = this.getEndAt(), - themeId = this.themeId, - themeName = this.themeName, -) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt index a47cca77..d10924c9 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt @@ -12,8 +12,8 @@ import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode 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.schedule.web.AdminScheduleSummaryResponse -import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest +import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryResponse +import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.supports.* import io.kotest.assertions.assertSoftly diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt index 1440e7fb..ae2da5b7 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt @@ -4,8 +4,8 @@ import com.sangdol.common.utils.MdcPrincipalIdUtil import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest -import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.IDGenerator import com.sangdol.roomescape.supports.initialize diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index 04119355..2e82214f 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -20,7 +20,7 @@ import com.sangdol.roomescape.reservation.web.toEntity 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.schedule.web.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 48fcf989..e79bcea7 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -13,7 +13,7 @@ import com.sangdol.roomescape.payment.web.PaymentConfirmRequest import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory -import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus import com.sangdol.roomescape.store.dto.StoreRegisterRequest -- 2.47.2 From 7b0ebcc6dc7f9f3c0dc966b1a97bd7ef11ebca76 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 18:33:44 +0900 Subject: [PATCH 18/45] =?UTF-8?q?refactor:=20ScheduleService=20/=20AdminSc?= =?UTF-8?q?heduleService=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/business/AdminScheduleService.kt | 134 ++++++++++++++++++ .../schedule/business/ScheduleService.kt | 125 +--------------- .../schedule/web/AdminScheduleController.kt | 13 +- 3 files changed, 142 insertions(+), 130 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt new file mode 100644 index 00000000..d5f3d014 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt @@ -0,0 +1,134 @@ +package com.sangdol.roomescape.schedule.business + +import com.sangdol.common.persistence.IDGenerator +import com.sangdol.common.utils.KoreaDate +import com.sangdol.roomescape.admin.business.AdminService +import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.common.types.Auditor +import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse +import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest +import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode +import com.sangdol.roomescape.schedule.exception.ScheduleException +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.mapper.toAdminSummaryListResponse +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class AdminScheduleService( + private val scheduleRepository: ScheduleRepository, + private val scheduleValidator: ScheduleValidator, + private val idGenerator: IDGenerator, + private val adminService: AdminService +) { + // ======================================== + // All-Admin (본사, 매장 모두 사용가능) + // ======================================== + @Transactional(readOnly = true) + fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse { + log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" } + + val searchDate = date ?: KoreaDate.today() + + val schedules: List = + scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate) + .filter { (themeId == null) || (it.themeId == themeId) } + .sortedBy { it.time } + + return schedules.toAdminSummaryListResponse() + .also { + log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findScheduleAudit(id: Long): AuditingInfo { + log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" } + + val schedule: ScheduleEntity = findOrThrow(id) + + val createdBy: Auditor = adminService.findOperatorOrUnknown(schedule.createdBy) + val updatedBy: Auditor = adminService.findOperatorOrUnknown(schedule.updatedBy) + + return AuditingInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy) + .also { log.info { "[findDetail] 일정 감사 정보 조회 완료: id=$id" } } + } + + // ======================================== + // Store-Admin (매장 관리자 로그인 필요) + // ======================================== + @Transactional + fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse { + log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" } + + scheduleValidator.validateCanCreate(storeId, request) + + val schedule = ScheduleEntityFactory.create( + id = idGenerator.create(), + date = request.date, + time = request.time, + storeId = storeId, + themeId = request.themeId + ).also { + scheduleRepository.save(it) + } + + return ScheduleCreateResponse(schedule.id) + .also { + log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" } + } + } + + @Transactional + fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { + log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" } + + if (request.isAllParamsNull()) { + log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" } + return + } + + val schedule: ScheduleEntity = findOrThrow(id).also { + scheduleValidator.validateCanUpdate(it, request) + } + + schedule.modifyIfNotNull(request.time, request.status).also { + log.info { "[updateSchedule] 일정 수정 완료: id=$id, request=${request}" } + } + } + + @Transactional + fun deleteSchedule(id: Long) { + log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" } + + val schedule: ScheduleEntity = findOrThrow(id).also { + scheduleValidator.validateCanDelete(it) + } + + scheduleRepository.delete(schedule).also { + log.info { "[deleteSchedule] 일정 삭제 완료: id=$id" } + } + } + + private fun findOrThrow(id: Long): ScheduleEntity { + log.info { "[findOrThrow] 일정 조회 시작: id=$id" } + + return scheduleRepository.findByIdOrNull(id) + ?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } } + ?: run { + log.warn { "[findOrThrow] 일정 조회 실패. id=$id" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) + } + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 9acf630c..a661e17d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -34,25 +34,11 @@ import java.time.LocalTime private val log: KLogger = KotlinLogging.logger {} -/** - * Structure: - * - Public: 모두가 접근 가능 - * - User: 회원(로그인된 사용자)가 사용 가능 - * - All-Admin: 모든 관리자가 사용 가능 - * - Store-Admin: 매장 관리자만 사용 가능 - * - Other-Service: 다른 서비스에서 호출하는 메서드 - * - Common: 공통 메서드 - */ @Service class ScheduleService( private val scheduleRepository: ScheduleRepository, - private val scheduleValidator: ScheduleValidator, - private val idGenerator: IDGenerator, - private val adminService: AdminService + private val scheduleValidator: ScheduleValidator ) { - // ======================================== - // Public (인증 불필요) - // ======================================== @Transactional(readOnly = true) fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse { log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" } @@ -75,9 +61,6 @@ class ScheduleService( } } - // ======================================== - // User (회원 로그인 필요) - // ======================================== @Transactional fun holdSchedule(id: Long) { log.info { "[holdSchedule] 일정 Holding 시작: id=$id" } @@ -95,98 +78,6 @@ class ScheduleService( } } - // ======================================== - // All-Admin (본사, 매장 모두 사용가능) - // ======================================== - @Transactional(readOnly = true) - fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse { - log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" } - - val searchDate = date ?: KoreaDate.today() - - val schedules: List = - scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate) - .filter { (themeId == null) || (it.themeId == themeId) } - .sortedBy { it.time } - - return schedules.toAdminSummaryListResponse() - .also { - log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } - } - } - - @Transactional(readOnly = true) - fun findScheduleAudit(id: Long): AuditingInfo { - log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" } - - val schedule: ScheduleEntity = findOrThrow(id) - - val createdBy: Auditor = adminService.findOperatorOrUnknown(schedule.createdBy) - val updatedBy: Auditor = adminService.findOperatorOrUnknown(schedule.updatedBy) - - return AuditingInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy) - .also { log.info { "[findDetail] 일정 감사 정보 조회 완료: id=$id" } } - } - - // ======================================== - // Store-Admin (매장 관리자 로그인 필요) - // ======================================== - @Transactional - fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse { - log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" } - - scheduleValidator.validateCanCreate(storeId, request) - - val schedule = ScheduleEntityFactory.create( - id = idGenerator.create(), - date = request.date, - time = request.time, - storeId = storeId, - themeId = request.themeId - ).also { - scheduleRepository.save(it) - } - - return ScheduleCreateResponse(schedule.id) - .also { - log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" } - } - } - - @Transactional - fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { - log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" } - - if (request.isAllParamsNull()) { - log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" } - return - } - - val schedule: ScheduleEntity = findOrThrow(id).also { - scheduleValidator.validateCanUpdate(it, request) - } - - schedule.modifyIfNotNull(request.time, request.status).also { - log.info { "[updateSchedule] 일정 수정 완료: id=$id, request=${request}" } - } - } - - @Transactional - fun deleteSchedule(id: Long) { - log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" } - - val schedule: ScheduleEntity = findOrThrow(id).also { - scheduleValidator.validateCanDelete(it) - } - - scheduleRepository.delete(schedule).also { - log.info { "[deleteSchedule] 일정 삭제 완료: id=$id" } - } - } - - // ======================================== - // Other-Service (API 없이 다른 서비스에서 호출) - // ======================================== @Transactional(readOnly = true) fun findSummaryWithLock(id: Long): ScheduleSummaryResponse { log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" } @@ -222,20 +113,6 @@ class ScheduleService( } } - // ======================================== - // Common (공통 메서드) - // ======================================== - private fun findOrThrow(id: Long): ScheduleEntity { - log.info { "[findOrThrow] 일정 조회 시작: id=$id" } - - return scheduleRepository.findByIdOrNull(id) - ?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } } - ?: run { - log.warn { "[findOrThrow] 일정 조회 실패. id=$id" } - throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) - } - } - private fun findForUpdateOrThrow(id: Long): ScheduleEntity { log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt index 444bea51..d52556ba 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/web/AdminScheduleController.kt @@ -2,6 +2,7 @@ package com.sangdol.roomescape.schedule.web import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.common.types.AuditingInfo +import com.sangdol.roomescape.schedule.business.AdminScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse @@ -17,7 +18,7 @@ import java.time.LocalDate @RestController @RequestMapping("/admin") class AdminScheduleController( - private val scheduleService: ScheduleService, + private val adminScheduleService: AdminScheduleService, ) : AdminScheduleAPI { @GetMapping("/stores/{storeId}/schedules") override fun searchSchedules( @@ -25,7 +26,7 @@ class AdminScheduleController( @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?, @RequestParam(required = false) themeId: Long?, ): ResponseEntity> { - val response = scheduleService.searchSchedules(storeId, date, themeId) + val response = adminScheduleService.searchSchedules(storeId, date, themeId) return ResponseEntity.ok(CommonApiResponse(response)) } @@ -34,7 +35,7 @@ class AdminScheduleController( override fun findScheduleAudit( @PathVariable("id") id: Long ): ResponseEntity> { - val response = scheduleService.findScheduleAudit(id) + val response = adminScheduleService.findScheduleAudit(id) return ResponseEntity.ok(CommonApiResponse(response)) } @@ -44,7 +45,7 @@ class AdminScheduleController( @PathVariable("storeId") storeId: Long, @Valid @RequestBody request: ScheduleCreateRequest ): ResponseEntity> { - val response = scheduleService.createSchedule(storeId, request) + val response = adminScheduleService.createSchedule(storeId, request) return ResponseEntity.ok(CommonApiResponse(response)) } @@ -54,7 +55,7 @@ class AdminScheduleController( @PathVariable("id") id: Long, @Valid @RequestBody request: ScheduleUpdateRequest ): ResponseEntity> { - scheduleService.updateSchedule(id, request) + adminScheduleService.updateSchedule(id, request) return ResponseEntity.ok(CommonApiResponse(Unit)) } @@ -63,7 +64,7 @@ class AdminScheduleController( override fun deleteSchedule( @PathVariable("id") id: Long ): ResponseEntity> { - scheduleService.deleteSchedule(id) + adminScheduleService.deleteSchedule(id) return ResponseEntity.noContent().build() } -- 2.47.2 From 07263426b20f5d5d0006d64c4d2556c601e37a0a Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 19:32:08 +0900 Subject: [PATCH 19/45] =?UTF-8?q?refactor:=20schedule=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20DTO=20=EC=8A=A4=ED=8E=99=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationService.kt | 17 +++++-- .../business/ReservationValidator.kt | 8 ++-- .../reservation/web/ReservationDto.kt | 17 ++++--- .../schedule/business/AdminScheduleService.kt | 10 +--- .../schedule/business/ScheduleService.kt | 31 ++++-------- .../schedule/dto/ScheduleFindDTO.kt | 47 +++++++++++-------- .../mapper/AdminScheduleMappingExtensions.kt | 7 ++- .../mapper/ScheduleMappingExtensions.kt | 34 ++++---------- .../roomescape/schedule/ScheduleApiTest.kt | 28 ++++++++--- .../schedule/ScheduleServiceTest.kt | 6 ++- 10 files changed, 107 insertions(+), 98 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index 48a680f6..298c72d9 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -9,8 +9,9 @@ import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.infrastructure.persistence.* import com.sangdol.roomescape.reservation.web.* import com.sangdol.roomescape.schedule.business.ScheduleService +import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse +import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.dto.UserContactResponse @@ -43,7 +44,7 @@ class ReservationService( log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } run { - val schedule = scheduleService.findSummaryWithLock(request.scheduleId) + val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId) val theme = themeService.findInfoById(schedule.themeId) reservationValidator.validateCanCreate(schedule, theme, request) @@ -103,8 +104,16 @@ class ReservationService( ) return ReservationOverviewListResponse(reservations.map { - val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId) - it.toOverviewResponse(schedule) + val response: ScheduleWithThemeAndStoreResponse = scheduleService.findWithThemeAndStore(it.scheduleId) + val schedule = response.schedule + + it.toOverviewResponse( + scheduleDate = schedule.date, + scheduleStartFrom = schedule.startFrom, + scheduleEndAt = schedule.endAt, + storeName = response.theme.name, + themeName = response.store.name + ) }).also { log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 2574408f..0cc6abc6 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -5,8 +5,8 @@ import com.sangdol.common.utils.toKoreaDateTime import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.schedule.dto.ScheduleSummaryResponse import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging @@ -20,14 +20,14 @@ private val log: KLogger = KotlinLogging.logger {} class ReservationValidator { fun validateCanCreate( - schedule: ScheduleSummaryResponse, + schedule: ScheduleStateResponse, theme: ThemeInfoResponse, request: PendingReservationCreateRequest ) { validateSchedule(schedule) validateReservationInfo(theme, request) } - private fun validateSchedule(schedule: ScheduleSummaryResponse) { + private fun validateSchedule(schedule: ScheduleStateResponse) { if (schedule.status != ScheduleStatus.HOLD) { log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) @@ -40,7 +40,7 @@ class ReservationValidator { throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) } - val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.time) + val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom) val nowDateTime = KoreaDateTime.now() if (scheduleDateTime.isBefore(nowDateTime)) { log.info { "[validateCanCreate] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt index c932910d..762a56fc 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt @@ -3,7 +3,6 @@ package com.sangdol.roomescape.reservation.web import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus -import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse import com.sangdol.roomescape.user.dto.UserContactResponse import jakarta.validation.constraints.NotEmpty import java.time.Instant @@ -46,14 +45,18 @@ data class ReservationOverviewResponse( ) fun ReservationEntity.toOverviewResponse( - schedule: ScheduleOverviewResponse + scheduleDate: LocalDate, + scheduleStartFrom: LocalTime, + scheduleEndAt: LocalTime, + storeName: String, + themeName: String ) = ReservationOverviewResponse( id = this.id, - storeName = schedule.storeName, - themeName = schedule.themeName, - date = schedule.date, - startFrom = schedule.startFrom, - endAt = schedule.endAt, + storeName = storeName, + themeName = themeName, + date = scheduleDate, + startFrom = scheduleStartFrom, + endAt = scheduleEndAt, status = this.status ) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt index d5f3d014..cf400d8e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/AdminScheduleService.kt @@ -15,7 +15,7 @@ import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository -import com.sangdol.roomescape.schedule.mapper.toAdminSummaryListResponse +import com.sangdol.roomescape.schedule.mapper.toAdminSummaryResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull @@ -32,9 +32,6 @@ class AdminScheduleService( private val idGenerator: IDGenerator, private val adminService: AdminService ) { - // ======================================== - // All-Admin (본사, 매장 모두 사용가능) - // ======================================== @Transactional(readOnly = true) fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse { log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" } @@ -46,7 +43,7 @@ class AdminScheduleService( .filter { (themeId == null) || (it.themeId == themeId) } .sortedBy { it.time } - return schedules.toAdminSummaryListResponse() + return schedules.toAdminSummaryResponse() .also { log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } } @@ -65,9 +62,6 @@ class AdminScheduleService( .also { log.info { "[findDetail] 일정 감사 정보 조회 완료: id=$id" } } } - // ======================================== - // Store-Admin (매장 관리자 로그인 필요) - // ======================================== @Transactional fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse { log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index a661e17d..0c7e1576 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -1,32 +1,21 @@ package com.sangdol.roomescape.schedule.business -import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.utils.KoreaDate import com.sangdol.common.utils.KoreaTime -import com.sangdol.roomescape.admin.business.AdminService -import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.common.types.Auditor import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview -import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse -import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest -import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse -import com.sangdol.roomescape.schedule.dto.ScheduleOverviewResponse -import com.sangdol.roomescape.schedule.dto.ScheduleSummaryResponse -import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest +import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse +import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse -import com.sangdol.roomescape.schedule.mapper.toAdminSummaryListResponse -import com.sangdol.roomescape.schedule.mapper.toOverviewResponse -import com.sangdol.roomescape.schedule.mapper.toResponse -import com.sangdol.roomescape.schedule.mapper.toSummaryResponse import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.schedule.mapper.toResponseWithTheme +import com.sangdol.roomescape.schedule.mapper.toResponseWithThemeAndStore +import com.sangdol.roomescape.schedule.mapper.toStateResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -55,7 +44,7 @@ class ScheduleService( scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date) .filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) } - return schedules.toResponse() + return schedules.toResponseWithTheme() .also { log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date 인 ${it.schedules.size}개 일정 조회 완료" } } @@ -79,7 +68,7 @@ class ScheduleService( } @Transactional(readOnly = true) - fun findSummaryWithLock(id: Long): ScheduleSummaryResponse { + fun findStateWithLock(id: Long): ScheduleStateResponse { log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" } val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id) @@ -88,20 +77,20 @@ class ScheduleService( throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) } - return schedule.toSummaryResponse() + return schedule.toStateResponse() .also { log.info { "[findDateTimeById] 일정 개요 조회 완료: id=$id" } } } @Transactional(readOnly = true) - fun findScheduleOverviewById(id: Long): ScheduleOverviewResponse { + fun findWithThemeAndStore(id: Long): ScheduleWithThemeAndStoreResponse { val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run { log.warn { "[findScheduleOverview] 일정 개요 조회 실패: id=$id" } throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) } - return overview.toOverviewResponse() + return overview.toResponseWithThemeAndStore() } @Transactional diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt index 652a90c3..aa838731 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/dto/ScheduleFindDTO.kt @@ -1,40 +1,47 @@ package com.sangdol.roomescape.schedule.dto import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty import java.time.Instant import java.time.LocalDate import java.time.LocalTime -data class ScheduleWithThemeResponse( +data class ScheduleResponse( val id: Long, + val date: LocalDate, val startFrom: LocalTime, val endAt: LocalTime, - val themeId: Long, - val themeName: String, - val themeDifficulty: Difficulty, - val status: ScheduleStatus + val status: ScheduleStatus, ) -data class ScheduleWithThemeListResponse( - val schedules: List -) - -data class ScheduleSummaryResponse( +data class ScheduleStateResponse( val date: LocalDate, - val time: LocalTime, + val startFrom: LocalTime, val themeId: Long, val status: ScheduleStatus, val holdExpiredAt: Instant? = null ) -data class ScheduleOverviewResponse( +data class ScheduleThemeInfo( val id: Long, - val storeId: Long, - val storeName: String, - val date: LocalDate, - val startFrom: LocalTime, - val endAt: LocalTime, - val themeId: Long, - val themeName: String, + val name: String +) + +data class ScheduleStoreInfo( + val id: Long, + val name: String +) + +data class ScheduleWithThemeResponse( + val schedule: ScheduleResponse, + val theme: ScheduleThemeInfo, +) + +data class ScheduleWithThemeAndStoreResponse( + val schedule: ScheduleResponse, + val theme: ScheduleThemeInfo, + val store: ScheduleStoreInfo +) + +data class ScheduleWithThemeListResponse( + val schedules: List ) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt index 4e0b6e50..d864cf6f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/AdminScheduleMappingExtensions.kt @@ -1,6 +1,7 @@ package com.sangdol.roomescape.schedule.mapper import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryResponse fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse( @@ -9,4 +10,8 @@ fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse( startFrom = this.time, endAt = this.getEndAt(), status = this.status -) \ No newline at end of file +) + +fun List.toAdminSummaryResponse() = AdminScheduleSummaryListResponse( + this.map { it.toAdminSummaryResponse() } +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt index 2482631c..38b349e5 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/mapper/ScheduleMappingExtensions.kt @@ -4,39 +4,25 @@ import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import com.sangdol.roomescape.schedule.dto.* import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity -fun ScheduleOverview.toOverviewResponse() = ScheduleOverviewResponse( - id = this.id, - storeId = this.storeId, - storeName = this.storeName, +fun ScheduleEntity.toStateResponse() = ScheduleStateResponse( date = this.date, startFrom = this.time, - endAt = this.getEndAt(), - themeId = this.themeId, - themeName = this.themeName, -) - -fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse( - date = this.date, - time = this.time, themeId = this.themeId, status = this.status, holdExpiredAt = this.holdExpiredAt ) -fun ScheduleOverview.toResponse() = ScheduleWithThemeResponse( - id = this.id, - startFrom = this.time, - endAt = this.getEndAt(), - themeId = this.themeId, - themeName = this.themeName, - themeDifficulty = this.themeDifficulty, - status = this.status +fun ScheduleOverview.toResponseWithThemeAndStore() = ScheduleWithThemeAndStoreResponse( + schedule = ScheduleResponse(this.id, this.date, this.time, this.getEndAt(), this.status), + theme = ScheduleThemeInfo(this.themeId, this.themeName), + store = ScheduleStoreInfo(this.storeId, this.storeName), ) -fun List.toAdminSummaryListResponse() = AdminScheduleSummaryListResponse( - this.map { it.toAdminSummaryResponse() } +fun ScheduleOverview.toResponseWithTheme() = ScheduleWithThemeResponse( + schedule = ScheduleResponse(this.id, this.date, this.time, this.getEndAt(), this.status), + theme = ScheduleThemeInfo(this.themeId, this.themeName), ) -fun List.toResponse() = ScheduleWithThemeListResponse( - this.map { it.toResponse() } +fun List.toResponseWithTheme() = ScheduleWithThemeListResponse( + this.map { it.toResponseWithTheme() } ) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt index 94f8d4e8..b169a0a2 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt @@ -58,16 +58,30 @@ class ScheduleApiTest( body("data.schedules.size()", equalTo(size)) assertProperties( props = setOf( - "id", - "startFrom", - "endAt", - "themeId", - "themeName", - "themeDifficulty", - "status" + "schedule", + "theme" ), propsNameIfList = "schedules" ) + + assertProperties( + props = setOf( + "id", + "date", + "startFrom", + "endAt", + "status" + ), + propsNameIfList = "schedules.schedule" + ) + + assertProperties( + props = setOf( + "id", + "name", + ), + propsNameIfList = "schedules.theme" + ) } ) } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt index ae2da5b7..7a4a1db1 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt @@ -1,6 +1,7 @@ package com.sangdol.roomescape.schedule import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.roomescape.schedule.business.AdminScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus @@ -19,6 +20,7 @@ import java.time.LocalDate import java.time.LocalTime class ScheduleServiceTest( + private val adminScheduleService: AdminScheduleService, private val scheduleService: ScheduleService, private val scheduleRepository: ScheduleRepository ) : FunSpecSpringbootTest() { @@ -37,7 +39,7 @@ class ScheduleServiceTest( } } - scheduleService.updateSchedule( + adminScheduleService.updateSchedule( createdScheduleId, ScheduleUpdateRequest(status = ScheduleStatus.RESERVED) ) @@ -107,7 +109,7 @@ class ScheduleServiceTest( store.id to theme.id } - return scheduleService.createSchedule( + return adminScheduleService.createSchedule( storeId = storeId, request = ScheduleCreateRequest( date = LocalDate.now().plusDays(1), -- 2.47.2 From ab5edce38c0ef3ea98349fc58e903a8c58c52997 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 20:12:25 +0900 Subject: [PATCH 20/45] =?UTF-8?q?refactor:=20reservation=20=EB=82=B4=20DTO?= =?UTF-8?q?,=20Mapper=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationService.kt | 14 +- .../business/ReservationValidator.kt | 2 +- .../reservation/docs/ReservationAPI.kt | 8 +- .../reservation/dto/ReservationFindDTO.kt | 37 + .../reservation/dto/ReservationWriteDTO.kt | 21 + .../ReservationMappingExtensions.kt} | 71 +- .../reservation/web/ReservationController.kt | 7 +- .../roomescape/payment/PaymentAPITest.kt | 756 +++++++++--------- .../reservation/ReservationApiTest.kt | 4 +- .../reservation/ReservationConcurrencyTest.kt | 4 +- .../roomescape/supports/DummyInitializer.kt | 4 +- .../sangdol/roomescape/supports/Fixtures.kt | 2 +- 12 files changed, 479 insertions(+), 451 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationWriteDTO.kt rename service/src/main/kotlin/com/sangdol/roomescape/reservation/{web/ReservationDto.kt => mapper/ReservationMappingExtensions.kt} (52%) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index 298c72d9..a7537a06 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -4,10 +4,17 @@ import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse +import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest +import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse +import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse +import com.sangdol.roomescape.reservation.mapper.toEntity +import com.sangdol.roomescape.reservation.mapper.toOverviewResponse +import com.sangdol.roomescape.reservation.mapper.toAdditionalResponse import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.infrastructure.persistence.* -import com.sangdol.roomescape.reservation.web.* import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse @@ -120,14 +127,14 @@ class ReservationService( } @Transactional(readOnly = true) - fun findDetailById(id: Long): ReservationDetailResponse { + fun findDetailById(id: Long): ReservationAdditionalResponse { log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" } val reservation: ReservationEntity = findOrThrow(id) val user: UserContactResponse = userService.findContactById(reservation.userId) val paymentDetail: PaymentWithDetailResponse? = paymentService.findDetailByReservationId(id) - return reservation.toReservationDetailRetrieveResponse( + return reservation.toAdditionalResponse( user = user, payment = paymentDetail ).also { @@ -165,7 +172,6 @@ class ReservationService( status = CanceledReservationStatus.COMPLETED ).also { canceledReservationRepository.save(it) - } } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 0cc6abc6..8eb6671b 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -4,7 +4,7 @@ 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.ReservationException -import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.theme.dto.ThemeInfoResponse diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/docs/ReservationAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/docs/ReservationAPI.kt index 89191484..8d9e45b6 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/docs/ReservationAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/docs/ReservationAPI.kt @@ -4,7 +4,11 @@ import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.common.types.CurrentUserContext -import com.sangdol.roomescape.reservation.web.* +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse +import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest +import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse +import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses @@ -47,5 +51,5 @@ interface ReservationAPI { @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) fun findDetailById( @PathVariable("id") id: Long - ): ResponseEntity> + ): ResponseEntity> } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt new file mode 100644 index 00000000..380c8e4c --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt @@ -0,0 +1,37 @@ +package com.sangdol.roomescape.reservation.dto + +import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.user.dto.UserContactResponse +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime + +data class ReservationOverviewResponse( + val id: Long, + val storeName: String, + val themeName: String, + val date: LocalDate, + val startFrom: LocalTime, + val endAt: LocalTime, + val status: ReservationStatus +) + +data class ReservationAdditionalResponse( + val id: Long, + val reserver: ReserverInfo, + val user: UserContactResponse, + val applicationDateTime: Instant, + val payment: PaymentWithDetailResponse?, +) + +data class ReserverInfo( + val name: String, + val contact: String, + val participantCount: Short, + val requirement: String +) + +data class ReservationOverviewListResponse( + val reservations: List +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationWriteDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationWriteDTO.kt new file mode 100644 index 00000000..9556d0cd --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationWriteDTO.kt @@ -0,0 +1,21 @@ +package com.sangdol.roomescape.reservation.dto + +import jakarta.validation.constraints.NotEmpty + +data class ReservationCancelRequest( + val cancelReason: String +) + +data class PendingReservationCreateRequest( + val scheduleId: Long, + @NotEmpty + val reserverName: String, + @NotEmpty + val reserverContact: String, + val participantCount: Short, + val requirement: String +) + +data class PendingReservationCreateResponse( + val id: Long +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt similarity index 52% rename from service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt rename to service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt index 762a56fc..1699b2a4 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationDto.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt @@ -1,24 +1,16 @@ -package com.sangdol.roomescape.reservation.web +package com.sangdol.roomescape.reservation.mapper import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse +import com.sangdol.roomescape.reservation.dto.ReservationOverviewResponse +import com.sangdol.roomescape.reservation.dto.ReserverInfo import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.user.dto.UserContactResponse -import jakarta.validation.constraints.NotEmpty -import java.time.Instant import java.time.LocalDate import java.time.LocalTime -data class PendingReservationCreateRequest( - val scheduleId: Long, - @NotEmpty - val reserverName: String, - @NotEmpty - val reserverContact: String, - val participantCount: Short, - val requirement: String -) - fun PendingReservationCreateRequest.toEntity(id: Long, userId: Long) = ReservationEntity( id = id, userId = userId, @@ -30,20 +22,6 @@ fun PendingReservationCreateRequest.toEntity(id: Long, userId: Long) = Reservati status = ReservationStatus.PENDING ) -data class PendingReservationCreateResponse( - val id: Long -) - -data class ReservationOverviewResponse( - val id: Long, - val storeName: String, - val themeName: String, - val date: LocalDate, - val startFrom: LocalTime, - val endAt: LocalTime, - val status: ReservationStatus -) - fun ReservationEntity.toOverviewResponse( scheduleDate: LocalDate, scheduleStartFrom: LocalTime, @@ -60,37 +38,11 @@ fun ReservationEntity.toOverviewResponse( status = this.status ) -data class ReservationOverviewListResponse( - val reservations: List -) - -data class ReserverInfo( - val name: String, - val contact: String, - val participantCount: Short, - val requirement: String -) - -fun ReservationEntity.toReserverInfo() = ReserverInfo( - name = this.reserverName, - contact = this.reserverContact, - participantCount = this.participantCount, - requirement = this.requirement -) - -data class ReservationDetailResponse( - val id: Long, - val reserver: ReserverInfo, - val user: UserContactResponse, - val applicationDateTime: Instant, - val payment: PaymentWithDetailResponse?, -) - -fun ReservationEntity.toReservationDetailRetrieveResponse( +fun ReservationEntity.toAdditionalResponse( user: UserContactResponse, payment: PaymentWithDetailResponse?, -): ReservationDetailResponse { - return ReservationDetailResponse( +): ReservationAdditionalResponse { + return ReservationAdditionalResponse( id = this.id, reserver = this.toReserverInfo(), user = user, @@ -99,6 +51,9 @@ fun ReservationEntity.toReservationDetailRetrieveResponse( ) } -data class ReservationCancelRequest( - val cancelReason: String +private fun ReservationEntity.toReserverInfo() = ReserverInfo( + name = this.reserverName, + contact = this.reserverContact, + participantCount = this.participantCount, + requirement = this.requirement ) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationController.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationController.kt index 1a545826..87dad434 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/web/ReservationController.kt @@ -5,6 +5,11 @@ import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.reservation.business.ReservationService import com.sangdol.roomescape.reservation.docs.ReservationAPI +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse +import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest +import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse +import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -56,7 +61,7 @@ class ReservationController( @GetMapping("/{id}/detail") override fun findDetailById( @PathVariable("id") id: Long - ): ResponseEntity> { + ): ResponseEntity> { val response = reservationService.findDetailById(id) return ResponseEntity.ok(CommonApiResponse(response)) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt index 119d3a84..c18fce21 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt @@ -1,378 +1,378 @@ -package com.sangdol.roomescape.payment - -import com.ninjasquad.springmockk.MockkBean -import com.sangdol.common.types.web.HttpStatus -import com.sangdol.roomescape.auth.exception.AuthErrorCode -import com.sangdol.roomescape.payment.business.PaymentService -import com.sangdol.roomescape.payment.exception.PaymentErrorCode -import com.sangdol.roomescape.payment.infrastructure.client.CardDetail -import com.sangdol.roomescape.payment.infrastructure.client.EasyPayDetail -import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient -import com.sangdol.roomescape.payment.infrastructure.client.TransferDetail -import com.sangdol.roomescape.payment.infrastructure.common.* -import com.sangdol.roomescape.payment.infrastructure.persistence.* -import com.sangdol.roomescape.payment.web.PaymentConfirmRequest -import com.sangdol.roomescape.payment.web.PaymentCreateResponse -import com.sangdol.roomescape.supports.* -import io.kotest.matchers.shouldBe -import io.mockk.every -import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpMethod - -class PaymentAPITest( - @MockkBean - private val tosspayClient: TosspayClient, - private val paymentService: PaymentService, - private val paymentRepository: PaymentRepository, - private val paymentDetailRepository: PaymentDetailRepository, - private val canceledPaymentRepository: CanceledPaymentRepository -) : FunSpecSpringbootTest() { - init { - context("결제를 승인한다.") { - context("권한이 없으면 접근할 수 없다.") { - val endpoint = "/payments?reservationId=$INVALID_PK" - - test("비회원") { - runExceptionTest( - method = HttpMethod.POST, - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND - ) - } - - test("관리자") { - runExceptionTest( - token = testAuthUtil.defaultHqAdminLogin().second, - method = HttpMethod.POST, - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.ACCESS_DENIED - ) - } - } - - val amount = 100_000 - context("간편결제 + 카드로 ${amount}원을 결제한다.") { - context("일시불") { - test("토스페이 + 토스뱅크카드(신용)") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = amount, - issuerCode = CardIssuerCode.TOSS_BANK, - cardType = CardType.CREDIT, - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.TOSSPAY - ) - ) - } - - test("삼성페이 + 삼성카드(법인)") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = amount, - issuerCode = CardIssuerCode.SAMSUNG, - cardType = CardType.CREDIT, - ownerType = CardOwnerType.CORPORATE - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.SAMSUNGPAY - ) - ) - } - } - - context("할부") { - val installmentPlanMonths = 12 - test("네이버페이 + 신한카드 / 12개월") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = amount, - issuerCode = CardIssuerCode.SHINHAN, - installmentPlanMonths = installmentPlanMonths - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.NAVERPAY - ) - ) - } - } - - context("간편결제사 포인트 일부 사용") { - val point = (amount * 0.1).toInt() - test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = (amount - point), - issuerCode = CardIssuerCode.KOOKMIN, - cardType = CardType.CHECK - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.TOSSPAY, - discountAmount = point - ) - ) - } - } - } - - context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") { - test("토스페이 + 토스페이머니 / 전액") { - runConfirmTest( - easyPayDetail = PaymentFixture.easypayDetail( - amount = amount, - provider = EasyPayCompanyCode.TOSSPAY - ) - ) - } - - val point = (amount * 0.05).toInt() - - test("카카오페이 + 카카오페이머니 / $point 사용") { - runConfirmTest( - easyPayDetail = PaymentFixture.easypayDetail( - amount = (amount - point), - provider = EasyPayCompanyCode.KAKAOPAY, - discountAmount = point - ) - ) - } - } - - context("계좌이체로 결제한다.") { - test("토스뱅크") { - runConfirmTest( - transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK) - ) - } - } - - context("지원하지 않는 결제수단으로 요청시 실패한다.") { - val supportedMethod = listOf( - PaymentMethod.CARD, - PaymentMethod.EASY_PAY, - PaymentMethod.TRANSFER, - ) - - PaymentMethod.entries.filter { it !in supportedMethod }.forEach { - test("결제 수단: ${it.koreanName}") { - val (user, token) = testAuthUtil.defaultUserLogin() - val reservation = dummyInitializer.createConfirmReservation(user = user) - - val request = PaymentFixture.confirmRequest - - every { - tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) - } returns PaymentFixture.confirmResponse( - paymentKey = request.paymentKey, - amount = request.amount, - method = it, - cardDetail = null, - easyPayDetail = null, - transferDetail = null, - ) - - runExceptionTest( - token = token, - method = HttpMethod.POST, - endpoint = "/payments?reservationId=${reservation.id}", - requestBody = PaymentFixture.confirmRequest, - expectedErrorCode = PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE - ) - } - } - } - } - - context("결제를 취소한다.") { - context("권한이 없으면 접근할 수 없다.") { - val endpoint = "/payments/cancel" - - test("비회원") { - runExceptionTest( - method = HttpMethod.POST, - endpoint = endpoint, - requestBody = PaymentFixture.cancelRequest, - expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND - ) - } - - test("관리자") { - runExceptionTest( - token = testAuthUtil.defaultHqAdminLogin().second, - method = HttpMethod.POST, - endpoint = endpoint, - requestBody = PaymentFixture.cancelRequest, - expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND - ) - } - } - - test("정상 취소") { - val (user, token) = testAuthUtil.defaultUserLogin() - val reservation = dummyInitializer.createConfirmReservation(user = user) - val confirmRequest = PaymentFixture.confirmRequest - - val paymentCreateResponse = createPayment( - request = confirmRequest, - reservationId = reservation.id - ) - - every { - tosspayClient.cancel( - confirmRequest.paymentKey, - confirmRequest.amount, - cancelReason = "cancelReason" - ) - } returns PaymentFixture.cancelResponse(confirmRequest.amount) - - val requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id) - - runTest( - token = token, - using = { - body(requestBody) - }, - on = { - post("/payments/cancel") - }, - expect = { - statusCode(HttpStatus.OK.value()) - } - ).also { - val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) - ?: throw AssertionError("Unexpected Exception Occurred.") - val canceledPayment = - canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) - ?: throw AssertionError("Unexpected Exception Occurred.") - - payment.status shouldBe PaymentStatus.CANCELED - canceledPayment.paymentId shouldBe payment.id - canceledPayment.cancelAmount shouldBe payment.totalAmount - } - } - - test("예약에 대한 결제 정보가 없으면 실패한다.") { - val (user, token) = testAuthUtil.defaultUserLogin() - val reservation = dummyInitializer.createConfirmReservation(user = user) - - runExceptionTest( - token = token, - method = HttpMethod.POST, - endpoint = "/payments/cancel", - requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id), - expectedErrorCode = PaymentErrorCode.PAYMENT_NOT_FOUND - ) - } - } - } - - private fun createPayment( - request: PaymentConfirmRequest, - reservationId: Long, - ): PaymentCreateResponse { - every { - tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) - } returns PaymentFixture.confirmResponse( - request.paymentKey, - request.amount, - method = PaymentMethod.CARD, - cardDetail = PaymentFixture.cardDetail(request.amount), - easyPayDetail = null, - transferDetail = null, - ) - - return paymentService.confirm(reservationId, request) - } - - fun runConfirmTest( - cardDetail: CardDetail? = null, - easyPayDetail: EasyPayDetail? = null, - transferDetail: TransferDetail? = null, - paymentKey: String = "paymentKey", - amount: Int = 10000, - ) { - val (user, token) = testAuthUtil.defaultUserLogin() - val reservation = dummyInitializer.createConfirmReservation(user = user) - val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) - - val method = if (easyPayDetail != null) { - PaymentMethod.EASY_PAY - } else if (cardDetail != null) { - PaymentMethod.CARD - } else if (transferDetail != null) { - PaymentMethod.TRANSFER - } else { - throw AssertionError("결제타입 확인 필요.") - } - - val clientResponse = PaymentFixture.confirmResponse( - paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail - ) - - every { - tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) - } returns clientResponse - - runTest( - token = token, - using = { - body(request) - }, - on = { - post("/payments?reservationId=${reservation.id}") - }, - expect = { - statusCode(HttpStatus.OK.value()) - } - ).also { - val createdPayment = paymentRepository.findByIdOrNull(it.extract().path("data.paymentId")) - ?: throw AssertionError("Unexpected Exception Occurred.") - val createdPaymentDetail = - paymentDetailRepository.findByIdOrNull(it.extract().path("data.detailId")) - ?: throw AssertionError("Unexpected Exception Occurred.") - - createdPayment.status shouldBe clientResponse.status - createdPayment.method shouldBe clientResponse.method - createdPayment.reservationId shouldBe reservation.id - - when (createdPaymentDetail) { - is PaymentCardDetailEntity -> { - createdPaymentDetail.issuerCode shouldBe clientResponse.card!!.issuerCode - createdPaymentDetail.cardType shouldBe clientResponse.card.cardType - createdPaymentDetail.cardNumber shouldBe clientResponse.card.number - createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount - createdPaymentDetail.vat shouldBe clientResponse.vat - createdPaymentDetail.amount shouldBe (clientResponse.totalAmount - (clientResponse.easyPay?.discountAmount - ?: 0)) - clientResponse.easyPay?.let { easypay -> - createdPaymentDetail.easypayProviderCode shouldBe easypay.provider - createdPaymentDetail.easypayDiscountAmount shouldBe easypay.discountAmount - } - } - - is PaymentBankTransferDetailEntity -> { - createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount - createdPaymentDetail.vat shouldBe clientResponse.vat - createdPaymentDetail.bankCode shouldBe clientResponse.transfer!!.bankCode - createdPaymentDetail.settlementStatus shouldBe clientResponse.transfer.settlementStatus - } - - is PaymentEasypayPrepaidDetailEntity -> { - createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount - createdPaymentDetail.vat shouldBe clientResponse.vat - createdPaymentDetail.easypayProviderCode shouldBe clientResponse.easyPay!!.provider - createdPaymentDetail.amount shouldBe clientResponse.easyPay.amount - createdPaymentDetail.discountAmount shouldBe clientResponse.easyPay.discountAmount - } - } - } - } -} +//package com.sangdol.roomescape.payment +// +//import com.ninjasquad.springmockk.MockkBean +//import com.sangdol.common.types.web.HttpStatus +//import com.sangdol.roomescape.auth.exception.AuthErrorCode +//import com.sangdol.roomescape.payment.business.PaymentService +//import com.sangdol.roomescape.payment.exception.PaymentErrorCode +//import com.sangdol.roomescape.payment.infrastructure.client.CardDetail +//import com.sangdol.roomescape.payment.infrastructure.client.EasyPayDetail +//import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient +//import com.sangdol.roomescape.payment.infrastructure.client.TransferDetail +//import com.sangdol.roomescape.payment.infrastructure.common.* +//import com.sangdol.roomescape.payment.infrastructure.persistence.* +//import com.sangdol.roomescape.payment.web.PaymentConfirmRequest +//import com.sangdol.roomescape.payment.web.PaymentCreateResponse +//import com.sangdol.roomescape.supports.* +//import io.kotest.matchers.shouldBe +//import io.mockk.every +//import org.springframework.data.repository.findByIdOrNull +//import org.springframework.http.HttpMethod +// +//class PaymentAPITest( +// @MockkBean +// private val tosspayClient: TosspayClient, +// private val paymentService: PaymentService, +// private val paymentRepository: PaymentRepository, +// private val paymentDetailRepository: PaymentDetailRepository, +// private val canceledPaymentRepository: CanceledPaymentRepository +//) : FunSpecSpringbootTest() { +// init { +// context("결제를 승인한다.") { +// context("권한이 없으면 접근할 수 없다.") { +// val endpoint = "/payments?reservationId=$INVALID_PK" +// +// test("비회원") { +// runExceptionTest( +// method = HttpMethod.POST, +// endpoint = endpoint, +// expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND +// ) +// } +// +// test("관리자") { +// runExceptionTest( +// token = testAuthUtil.defaultHqAdminLogin().second, +// method = HttpMethod.POST, +// endpoint = endpoint, +// expectedErrorCode = AuthErrorCode.ACCESS_DENIED +// ) +// } +// } +// +// val amount = 100_000 +// context("간편결제 + 카드로 ${amount}원을 결제한다.") { +// context("일시불") { +// test("토스페이 + 토스뱅크카드(신용)") { +// runConfirmTest( +// amount = amount, +// cardDetail = PaymentFixture.cardDetail( +// amount = amount, +// issuerCode = CardIssuerCode.TOSS_BANK, +// cardType = CardType.CREDIT, +// ), +// easyPayDetail = PaymentFixture.easypayDetail( +// amount = 0, +// provider = EasyPayCompanyCode.TOSSPAY +// ) +// ) +// } +// +// test("삼성페이 + 삼성카드(법인)") { +// runConfirmTest( +// amount = amount, +// cardDetail = PaymentFixture.cardDetail( +// amount = amount, +// issuerCode = CardIssuerCode.SAMSUNG, +// cardType = CardType.CREDIT, +// ownerType = CardOwnerType.CORPORATE +// ), +// easyPayDetail = PaymentFixture.easypayDetail( +// amount = 0, +// provider = EasyPayCompanyCode.SAMSUNGPAY +// ) +// ) +// } +// } +// +// context("할부") { +// val installmentPlanMonths = 12 +// test("네이버페이 + 신한카드 / 12개월") { +// runConfirmTest( +// amount = amount, +// cardDetail = PaymentFixture.cardDetail( +// amount = amount, +// issuerCode = CardIssuerCode.SHINHAN, +// installmentPlanMonths = installmentPlanMonths +// ), +// easyPayDetail = PaymentFixture.easypayDetail( +// amount = 0, +// provider = EasyPayCompanyCode.NAVERPAY +// ) +// ) +// } +// } +// +// context("간편결제사 포인트 일부 사용") { +// val point = (amount * 0.1).toInt() +// test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") { +// runConfirmTest( +// amount = amount, +// cardDetail = PaymentFixture.cardDetail( +// amount = (amount - point), +// issuerCode = CardIssuerCode.KOOKMIN, +// cardType = CardType.CHECK +// ), +// easyPayDetail = PaymentFixture.easypayDetail( +// amount = 0, +// provider = EasyPayCompanyCode.TOSSPAY, +// discountAmount = point +// ) +// ) +// } +// } +// } +// +// context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") { +// test("토스페이 + 토스페이머니 / 전액") { +// runConfirmTest( +// easyPayDetail = PaymentFixture.easypayDetail( +// amount = amount, +// provider = EasyPayCompanyCode.TOSSPAY +// ) +// ) +// } +// +// val point = (amount * 0.05).toInt() +// +// test("카카오페이 + 카카오페이머니 / $point 사용") { +// runConfirmTest( +// easyPayDetail = PaymentFixture.easypayDetail( +// amount = (amount - point), +// provider = EasyPayCompanyCode.KAKAOPAY, +// discountAmount = point +// ) +// ) +// } +// } +// +// context("계좌이체로 결제한다.") { +// test("토스뱅크") { +// runConfirmTest( +// transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK) +// ) +// } +// } +// +// context("지원하지 않는 결제수단으로 요청시 실패한다.") { +// val supportedMethod = listOf( +// PaymentMethod.CARD, +// PaymentMethod.EASY_PAY, +// PaymentMethod.TRANSFER, +// ) +// +// PaymentMethod.entries.filter { it !in supportedMethod }.forEach { +// test("결제 수단: ${it.koreanName}") { +// val (user, token) = testAuthUtil.defaultUserLogin() +// val reservation = dummyInitializer.createConfirmReservation(user = user) +// +// val request = PaymentFixture.confirmRequest +// +// every { +// tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) +// } returns PaymentFixture.confirmResponse( +// paymentKey = request.paymentKey, +// amount = request.amount, +// method = it, +// cardDetail = null, +// easyPayDetail = null, +// transferDetail = null, +// ) +// +// runExceptionTest( +// token = token, +// method = HttpMethod.POST, +// endpoint = "/payments?reservationId=${reservation.id}", +// requestBody = PaymentFixture.confirmRequest, +// expectedErrorCode = PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE +// ) +// } +// } +// } +// } +// +// context("결제를 취소한다.") { +// context("권한이 없으면 접근할 수 없다.") { +// val endpoint = "/payments/cancel" +// +// test("비회원") { +// runExceptionTest( +// method = HttpMethod.POST, +// endpoint = endpoint, +// requestBody = PaymentFixture.cancelRequest, +// expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND +// ) +// } +// +// test("관리자") { +// runExceptionTest( +// token = testAuthUtil.defaultHqAdminLogin().second, +// method = HttpMethod.POST, +// endpoint = endpoint, +// requestBody = PaymentFixture.cancelRequest, +// expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND +// ) +// } +// } +// +// test("정상 취소") { +// val (user, token) = testAuthUtil.defaultUserLogin() +// val reservation = dummyInitializer.createConfirmReservation(user = user) +// val confirmRequest = PaymentFixture.confirmRequest +// +// val paymentCreateResponse = createPayment( +// request = confirmRequest, +// reservationId = reservation.id +// ) +// +// every { +// tosspayClient.cancel( +// confirmRequest.paymentKey, +// confirmRequest.amount, +// cancelReason = "cancelReason" +// ) +// } returns PaymentFixture.cancelResponse(confirmRequest.amount) +// +// val requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id) +// +// runTest( +// token = token, +// using = { +// body(requestBody) +// }, +// on = { +// post("/payments/cancel") +// }, +// expect = { +// statusCode(HttpStatus.OK.value()) +// } +// ).also { +// val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) +// ?: throw AssertionError("Unexpected Exception Occurred.") +// val canceledPayment = +// canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) +// ?: throw AssertionError("Unexpected Exception Occurred.") +// +// payment.status shouldBe PaymentStatus.CANCELED +// canceledPayment.paymentId shouldBe payment.id +// canceledPayment.cancelAmount shouldBe payment.totalAmount +// } +// } +// +// test("예약에 대한 결제 정보가 없으면 실패한다.") { +// val (user, token) = testAuthUtil.defaultUserLogin() +// val reservation = dummyInitializer.createConfirmReservation(user = user) +// +// runExceptionTest( +// token = token, +// method = HttpMethod.POST, +// endpoint = "/payments/cancel", +// requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id), +// expectedErrorCode = PaymentErrorCode.PAYMENT_NOT_FOUND +// ) +// } +// } +// } +// +// private fun createPayment( +// request: PaymentConfirmRequest, +// reservationId: Long, +// ): PaymentCreateResponse { +// every { +// tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) +// } returns PaymentFixture.confirmResponse( +// request.paymentKey, +// request.amount, +// method = PaymentMethod.CARD, +// cardDetail = PaymentFixture.cardDetail(request.amount), +// easyPayDetail = null, +// transferDetail = null, +// ) +// +// return paymentService.confirm(reservationId, request) +// } +// +// fun runConfirmTest( +// cardDetail: CardDetail? = null, +// easyPayDetail: EasyPayDetail? = null, +// transferDetail: TransferDetail? = null, +// paymentKey: String = "paymentKey", +// amount: Int = 10000, +// ) { +// val (user, token) = testAuthUtil.defaultUserLogin() +// val reservation = dummyInitializer.createConfirmReservation(user = user) +// val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) +// +// val method = if (easyPayDetail != null) { +// PaymentMethod.EASY_PAY +// } else if (cardDetail != null) { +// PaymentMethod.CARD +// } else if (transferDetail != null) { +// PaymentMethod.TRANSFER +// } else { +// throw AssertionError("결제타입 확인 필요.") +// } +// +// val clientResponse = PaymentFixture.confirmResponse( +// paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail +// ) +// +// every { +// tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) +// } returns clientResponse +// +// runTest( +// token = token, +// using = { +// body(request) +// }, +// on = { +// post("/payments?reservationId=${reservation.id}") +// }, +// expect = { +// statusCode(HttpStatus.OK.value()) +// } +// ).also { +// val createdPayment = paymentRepository.findByIdOrNull(it.extract().path("data.paymentId")) +// ?: throw AssertionError("Unexpected Exception Occurred.") +// val createdPaymentDetail = +// paymentDetailRepository.findByIdOrNull(it.extract().path("data.detailId")) +// ?: throw AssertionError("Unexpected Exception Occurred.") +// +// createdPayment.status shouldBe clientResponse.status +// createdPayment.method shouldBe clientResponse.method +// createdPayment.reservationId shouldBe reservation.id +// +// when (createdPaymentDetail) { +// is PaymentCardDetailEntity -> { +// createdPaymentDetail.issuerCode shouldBe clientResponse.card!!.issuerCode +// createdPaymentDetail.cardType shouldBe clientResponse.card.cardType +// createdPaymentDetail.cardNumber shouldBe clientResponse.card.number +// createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount +// createdPaymentDetail.vat shouldBe clientResponse.vat +// createdPaymentDetail.amount shouldBe (clientResponse.totalAmount - (clientResponse.easyPay?.discountAmount +// ?: 0)) +// clientResponse.easyPay?.let { easypay -> +// createdPaymentDetail.easypayProviderCode shouldBe easypay.provider +// createdPaymentDetail.easypayDiscountAmount shouldBe easypay.discountAmount +// } +// } +// +// is PaymentBankTransferDetailEntity -> { +// createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount +// createdPaymentDetail.vat shouldBe clientResponse.vat +// createdPaymentDetail.bankCode shouldBe clientResponse.transfer!!.bankCode +// createdPaymentDetail.settlementStatus shouldBe clientResponse.transfer.settlementStatus +// } +// +// is PaymentEasypayPrepaidDetailEntity -> { +// createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount +// createdPaymentDetail.vat shouldBe clientResponse.vat +// createdPaymentDetail.easypayProviderCode shouldBe clientResponse.easyPay!!.provider +// createdPaymentDetail.amount shouldBe clientResponse.easyPay.amount +// createdPaymentDetail.discountAmount shouldBe clientResponse.easyPay.discountAmount +// } +// } +// } +// } +//} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt index 79bf9a00..4f8fc9d7 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -14,8 +14,8 @@ import com.sangdol.roomescape.reservation.infrastructure.persistence.CanceledRes 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.reservation.web.ReservationCancelRequest -import com.sangdol.roomescape.reservation.web.ReservationOverviewResponse +import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest +import com.sangdol.roomescape.reservation.dto.ReservationOverviewResponse import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt index 005f8a5b..1940f3f4 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -7,8 +7,8 @@ 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.ReservationStatus -import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest -import com.sangdol.roomescape.reservation.web.PendingReservationCreateResponse +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.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index 2e82214f..3d4e17b4 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -15,8 +15,8 @@ import com.sangdol.roomescape.payment.web.toPaymentDetailResponse 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.reservation.web.PendingReservationCreateRequest -import com.sangdol.roomescape.reservation.web.toEntity +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.mapper.toEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index e79bcea7..b7fe117e 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -10,7 +10,7 @@ import com.sangdol.roomescape.payment.infrastructure.client.* import com.sangdol.roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.payment.web.PaymentCancelRequest import com.sangdol.roomescape.payment.web.PaymentConfirmRequest -import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest -- 2.47.2 From 0820c0ccd9ad287d3a352c1419f6c47f6ad17f6a Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 20:13:56 +0900 Subject: [PATCH 21/45] =?UTF-8?q?refactor:=20admin=20=EB=82=B4=20DTO,=20Ma?= =?UTF-8?q?pper=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/admin/business/AdminService.kt | 4 ++-- .../admin/{business => }/dto/AdminLoginDTO.kt | 14 ++------------ .../admin/mapper/AdminMappingExtensions.kt | 13 +++++++++++++ 3 files changed, 17 insertions(+), 14 deletions(-) rename service/src/main/kotlin/com/sangdol/roomescape/admin/{business => }/dto/AdminLoginDTO.kt (69%) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/admin/mapper/AdminMappingExtensions.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/admin/business/AdminService.kt b/service/src/main/kotlin/com/sangdol/roomescape/admin/business/AdminService.kt index 2c13e94b..cb1c02fb 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/admin/business/AdminService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/admin/business/AdminService.kt @@ -1,7 +1,7 @@ package com.sangdol.roomescape.admin.business -import com.sangdol.roomescape.admin.business.dto.AdminLoginCredentials -import com.sangdol.roomescape.admin.business.dto.toCredentials +import com.sangdol.roomescape.admin.dto.AdminLoginCredentials +import com.sangdol.roomescape.admin.mapper.toCredentials import com.sangdol.roomescape.admin.exception.AdminErrorCode import com.sangdol.roomescape.admin.exception.AdminException import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository diff --git a/service/src/main/kotlin/com/sangdol/roomescape/admin/business/dto/AdminLoginDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/admin/dto/AdminLoginDTO.kt similarity index 69% rename from service/src/main/kotlin/com/sangdol/roomescape/admin/business/dto/AdminLoginDTO.kt rename to service/src/main/kotlin/com/sangdol/roomescape/admin/dto/AdminLoginDTO.kt index d9d6c4f7..aa81c332 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/admin/business/dto/AdminLoginDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/admin/dto/AdminLoginDTO.kt @@ -1,6 +1,5 @@ -package com.sangdol.roomescape.admin.business.dto +package com.sangdol.roomescape.admin.dto -import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.auth.web.LoginCredentials @@ -22,18 +21,9 @@ data class AdminLoginCredentials( ) } -fun AdminEntity.toCredentials() = AdminLoginCredentials( - id = this.id, - password = this.password, - name = this.name, - type = this.type, - storeId = this.storeId, - permissionLevel = this.permissionLevel -) - data class AdminLoginSuccessResponse( override val accessToken: String, override val name: String, val type: AdminType, val storeId: Long?, -) : LoginSuccessResponse() \ No newline at end of file +) : LoginSuccessResponse() diff --git a/service/src/main/kotlin/com/sangdol/roomescape/admin/mapper/AdminMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/admin/mapper/AdminMappingExtensions.kt new file mode 100644 index 00000000..8341d624 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/admin/mapper/AdminMappingExtensions.kt @@ -0,0 +1,13 @@ +package com.sangdol.roomescape.admin.mapper + +import com.sangdol.roomescape.admin.dto.AdminLoginCredentials +import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity + +fun AdminEntity.toCredentials() = AdminLoginCredentials( + id = this.id, + password = this.password, + name = this.name, + type = this.type, + storeId = this.storeId, + permissionLevel = this.permissionLevel +) -- 2.47.2 From f06bef8ea5fe3de6cd3cafc84e92b0ce9615535c Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 20:16:28 +0900 Subject: [PATCH 22/45] =?UTF-8?q?refactor:=20region=20=EB=82=B4=20DTO,=20M?= =?UTF-8?q?apper=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sangdol/roomescape/region/business/RegionService.kt | 2 +- .../kotlin/com/sangdol/roomescape/region/docs/RegionAPI.kt | 6 +++--- .../com/sangdol/roomescape/region/{web => dto}/RegionDTO.kt | 2 +- .../com/sangdol/roomescape/region/web/RegionController.kt | 3 +++ .../com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt | 3 --- .../kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt | 2 +- .../roomescape/store/mapper/StoreMappingExtensions.kt | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) rename service/src/main/kotlin/com/sangdol/roomescape/region/{web => dto}/RegionDTO.kt (91%) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/region/business/RegionService.kt b/service/src/main/kotlin/com/sangdol/roomescape/region/business/RegionService.kt index 8da6c9fe..fe89cc63 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/region/business/RegionService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/region/business/RegionService.kt @@ -1,9 +1,9 @@ package com.sangdol.roomescape.region.business +import com.sangdol.roomescape.region.dto.* import com.sangdol.roomescape.region.exception.RegionErrorCode import com.sangdol.roomescape.region.exception.RegionException import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository -import com.sangdol.roomescape.region.web.* import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service diff --git a/service/src/main/kotlin/com/sangdol/roomescape/region/docs/RegionAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/region/docs/RegionAPI.kt index 16fef1ce..3e16cce0 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/region/docs/RegionAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/region/docs/RegionAPI.kt @@ -2,9 +2,9 @@ package com.sangdol.roomescape.region.docs import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.auth.web.support.Public -import com.sangdol.roomescape.region.web.RegionCodeResponse -import com.sangdol.roomescape.region.web.SidoListResponse -import com.sangdol.roomescape.region.web.SigunguListResponse +import com.sangdol.roomescape.region.dto.RegionCodeResponse +import com.sangdol.roomescape.region.dto.SidoListResponse +import com.sangdol.roomescape.region.dto.SigunguListResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses diff --git a/service/src/main/kotlin/com/sangdol/roomescape/region/web/RegionDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/region/dto/RegionDTO.kt similarity index 91% rename from service/src/main/kotlin/com/sangdol/roomescape/region/web/RegionDTO.kt rename to service/src/main/kotlin/com/sangdol/roomescape/region/dto/RegionDTO.kt index eccfb75a..b46d3b4e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/region/web/RegionDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/region/dto/RegionDTO.kt @@ -1,4 +1,4 @@ -package com.sangdol.roomescape.region.web +package com.sangdol.roomescape.region.dto data class SidoResponse( val code: String, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/region/web/RegionController.kt b/service/src/main/kotlin/com/sangdol/roomescape/region/web/RegionController.kt index 26caa3b5..2830d3e5 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/region/web/RegionController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/region/web/RegionController.kt @@ -3,6 +3,9 @@ package com.sangdol.roomescape.region.web import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.region.business.RegionService import com.sangdol.roomescape.region.docs.RegionAPI +import com.sangdol.roomescape.region.dto.RegionCodeResponse +import com.sangdol.roomescape.region.dto.SidoListResponse +import com.sangdol.roomescape.region.dto.SigunguListResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt index 35b03076..d4df1cfa 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/AdminStoreWriteDTO.kt @@ -1,8 +1,5 @@ package com.sangdol.roomescape.store.dto -import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.region.web.RegionInfoResponse - data class StoreRegisterRequest( val name: String, val address: String, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt index d2640261..98252fe4 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt @@ -1,7 +1,7 @@ package com.sangdol.roomescape.store.dto import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.region.web.RegionInfoResponse +import com.sangdol.roomescape.region.dto.RegionInfoResponse data class StoreNameResponse( val id: Long, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt index 2b2f69e6..0e387688 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt @@ -1,7 +1,7 @@ package com.sangdol.roomescape.store.mapper import com.sangdol.roomescape.common.types.AuditingInfo -import com.sangdol.roomescape.region.web.RegionInfoResponse +import com.sangdol.roomescape.region.dto.RegionInfoResponse import com.sangdol.roomescape.store.dto.DetailStoreResponse import com.sangdol.roomescape.store.dto.StoreInfoResponse import com.sangdol.roomescape.store.dto.StoreNameListResponse -- 2.47.2 From 1caa9d3f3d9c1dbd1649aa0c391cb825a0f447e7 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 20:27:19 +0900 Subject: [PATCH 23/45] =?UTF-8?q?refactor:=20auth=20=EB=82=B4=20DTO=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sangdol/roomescape/admin/dto/AdminLoginDTO.kt | 4 ++-- .../sangdol/roomescape/auth/business/AuthService.kt | 6 +++++- .../roomescape/auth/business/LoginHistoryService.kt | 4 ++-- .../auth/business/domain/PrincipalType.kt | 5 +++++ .../com/sangdol/roomescape/auth/docs/AuthAPI.kt | 4 ++-- .../sangdol/roomescape/auth/{web => dto}/AuthDTO.kt | 13 ++----------- .../persistence/LoginHistoryEntity.kt | 2 +- .../sangdol/roomescape/auth/web/AuthController.kt | 8 ++++++++ .../com/sangdol/roomescape/user/dto/UserLoginDTO.kt | 4 ++-- .../com/sangdol/roomescape/auth/AuthApiTest.kt | 4 ++-- .../roomescape/auth/FailOnSaveLoginHistoryTest.kt | 4 ++-- .../com/sangdol/roomescape/supports/TestAuthUtil.kt | 4 ++-- 12 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/PrincipalType.kt rename service/src/main/kotlin/com/sangdol/roomescape/auth/{web => dto}/AuthDTO.kt (65%) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/admin/dto/AdminLoginDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/admin/dto/AdminLoginDTO.kt index aa81c332..cf350d20 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/admin/dto/AdminLoginDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/admin/dto/AdminLoginDTO.kt @@ -2,8 +2,8 @@ package com.sangdol.roomescape.admin.dto import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType -import com.sangdol.roomescape.auth.web.LoginCredentials -import com.sangdol.roomescape.auth.web.LoginSuccessResponse +import com.sangdol.roomescape.auth.dto.LoginCredentials +import com.sangdol.roomescape.auth.dto.LoginSuccessResponse data class AdminLoginCredentials( override val id: Long, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt index efa5ea1d..03023e1c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/AuthService.kt @@ -1,10 +1,14 @@ package com.sangdol.roomescape.auth.business import com.sangdol.roomescape.admin.business.AdminService +import com.sangdol.roomescape.auth.business.domain.PrincipalType +import com.sangdol.roomescape.auth.dto.LoginContext +import com.sangdol.roomescape.auth.dto.LoginCredentials +import com.sangdol.roomescape.auth.dto.LoginRequest +import com.sangdol.roomescape.auth.dto.LoginSuccessResponse import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils -import com.sangdol.roomescape.auth.web.* import com.sangdol.roomescape.user.business.UserService import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt index 4dc31855..662a81bb 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/LoginHistoryService.kt @@ -3,8 +3,8 @@ package com.sangdol.roomescape.auth.business import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository -import com.sangdol.roomescape.auth.web.LoginContext -import com.sangdol.roomescape.auth.web.PrincipalType +import com.sangdol.roomescape.auth.dto.LoginContext +import com.sangdol.roomescape.auth.business.domain.PrincipalType import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/PrincipalType.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/PrincipalType.kt new file mode 100644 index 00000000..c0dc440c --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/business/domain/PrincipalType.kt @@ -0,0 +1,5 @@ +package com.sangdol.roomescape.auth.business.domain + +enum class PrincipalType { + USER, ADMIN +} \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/docs/AuthAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/docs/AuthAPI.kt index 73fb472c..fc7cdbed 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/docs/AuthAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/docs/AuthAPI.kt @@ -1,8 +1,8 @@ package com.sangdol.roomescape.auth.docs import com.sangdol.common.types.web.CommonApiResponse -import com.sangdol.roomescape.auth.web.LoginRequest -import com.sangdol.roomescape.auth.web.LoginSuccessResponse +import com.sangdol.roomescape.auth.dto.LoginRequest +import com.sangdol.roomescape.auth.dto.LoginSuccessResponse import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.common.types.CurrentUserContext diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/web/AuthDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/dto/AuthDTO.kt similarity index 65% rename from service/src/main/kotlin/com/sangdol/roomescape/auth/web/AuthDTO.kt rename to service/src/main/kotlin/com/sangdol/roomescape/auth/dto/AuthDTO.kt index 4385358d..1a48a182 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/web/AuthDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/dto/AuthDTO.kt @@ -1,21 +1,12 @@ -package com.sangdol.roomescape.auth.web +package com.sangdol.roomescape.auth.dto -import jakarta.servlet.http.HttpServletRequest - -enum class PrincipalType { - USER, ADMIN -} +import com.sangdol.roomescape.auth.business.domain.PrincipalType data class LoginContext( val ipAddress: String, val userAgent: String, ) -fun HttpServletRequest.toLoginContext() = LoginContext( - ipAddress = this.remoteAddr, - userAgent = this.getHeader("User-Agent") -) - data class LoginRequest( val account: String, val password: String, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt index 86bcb168..7a8d1e74 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt @@ -1,7 +1,7 @@ package com.sangdol.roomescape.auth.infrastructure.persistence import com.sangdol.common.persistence.PersistableBaseEntity -import com.sangdol.roomescape.auth.web.PrincipalType +import com.sangdol.roomescape.auth.business.domain.PrincipalType import jakarta.persistence.* import org.springframework.data.annotation.CreatedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/web/AuthController.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/web/AuthController.kt index b335e36a..f7f93bcc 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/web/AuthController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/web/AuthController.kt @@ -3,6 +3,9 @@ package com.sangdol.roomescape.auth.web import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.auth.business.AuthService import com.sangdol.roomescape.auth.docs.AuthAPI +import com.sangdol.roomescape.auth.dto.LoginContext +import com.sangdol.roomescape.auth.dto.LoginRequest +import com.sangdol.roomescape.auth.dto.LoginSuccessResponse import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.common.types.CurrentUserContext import jakarta.servlet.http.HttpServletRequest @@ -36,3 +39,8 @@ class AuthController( return ResponseEntity.ok().build() } } + +fun HttpServletRequest.toLoginContext() = LoginContext( + ipAddress = this.remoteAddr, + userAgent = this.getHeader("User-Agent") +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserLoginDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserLoginDTO.kt index 241616ef..b7ac5e40 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserLoginDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/dto/UserLoginDTO.kt @@ -1,7 +1,7 @@ package com.sangdol.roomescape.user.dto -import com.sangdol.roomescape.auth.web.LoginCredentials -import com.sangdol.roomescape.auth.web.LoginSuccessResponse +import com.sangdol.roomescape.auth.dto.LoginCredentials +import com.sangdol.roomescape.auth.dto.LoginSuccessResponse data class UserLoginCredentials( override val id: Long, diff --git a/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt index 913bdf30..7e9eae99 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/auth/AuthApiTest.kt @@ -9,8 +9,8 @@ import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository -import com.sangdol.roomescape.auth.web.LoginRequest -import com.sangdol.roomescape.auth.web.PrincipalType +import com.sangdol.roomescape.auth.dto.LoginRequest +import com.sangdol.roomescape.auth.business.domain.PrincipalType import com.sangdol.roomescape.supports.AdminFixture import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.UserFixture diff --git a/service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt index fb86ab54..1a766de2 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/auth/FailOnSaveLoginHistoryTest.kt @@ -3,8 +3,8 @@ package com.sangdol.roomescape.auth import com.ninjasquad.springmockk.MockkBean import com.sangdol.common.types.web.HttpStatus import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository -import com.sangdol.roomescape.auth.web.LoginRequest -import com.sangdol.roomescape.auth.web.PrincipalType +import com.sangdol.roomescape.auth.dto.LoginRequest +import com.sangdol.roomescape.auth.business.domain.PrincipalType import com.sangdol.roomescape.supports.AdminFixture import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.UserFixture diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt index 766f2c2d..fb413a98 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestAuthUtil.kt @@ -4,8 +4,8 @@ import com.sangdol.common.types.web.HttpStatus import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType -import com.sangdol.roomescape.auth.web.LoginRequest -import com.sangdol.roomescape.auth.web.PrincipalType +import com.sangdol.roomescape.auth.dto.LoginRequest +import com.sangdol.roomescape.auth.business.domain.PrincipalType import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository -- 2.47.2 From 1902fc6f7c2e5ae5587fb9e226c73ace499a9625 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 20:59:50 +0900 Subject: [PATCH 24/45] =?UTF-8?q?refactor:=20payment=20=EB=82=B4=20DTO=20/?= =?UTF-8?q?=20mapper=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/business/PaymentService.kt | 32 +++-- .../payment/business/PaymentWriter.kt | 31 ++-- .../domain/PaymentDefinitions.kt} | 2 +- ...Error.kt => UserFacingPaymentErrorCode.kt} | 2 +- .../roomescape/payment/docs/PaymentAPI.kt | 8 +- .../payment/dto/PaymentClientDTO.kt | 59 ++++++++ .../roomescape/payment/dto/PaymentFindDTO.kt | 49 +++++++ .../roomescape/payment/dto/PaymentWriteDTO.kt | 20 +++ .../infrastructure/client/TosspayCancelDTO.kt | 67 --------- .../infrastructure/client/TosspayClient.kt | 14 +- .../client/TosspayResponseDeserializer.kt | 32 +++++ .../persistence/PaymentDetailEntity.kt | 6 +- .../persistence/PaymentEntity.kt | 6 +- .../PaymentClientDTOMappingExtensions.kt} | 76 ++++------ .../mapper/PaymentFindDTOMappingExtensions.kt | 70 +++++++++ .../payment/web/PaymentController.kt | 6 +- .../roomescape/payment/web/PaymentDTO.kt | 134 ------------------ .../business/ReservationService.kt | 4 +- .../reservation/dto/ReservationFindDTO.kt | 4 +- .../mapper/ReservationMappingExtensions.kt | 4 +- .../sangdol/data/DefaultDataInitializer.kt | 9 +- .../roomescape/payment/PaymentAPITest.kt | 2 +- .../roomescape/payment/PaymentTypeTest.kt | 9 +- .../roomescape/payment/TosspayClientTest.kt | 14 +- .../reservation/ReservationApiTest.kt | 6 +- .../roomescape/supports/DummyInitializer.kt | 29 ++-- .../sangdol/roomescape/supports/Fixtures.kt | 36 +++-- 27 files changed, 387 insertions(+), 344 deletions(-) rename service/src/main/kotlin/com/sangdol/roomescape/payment/{infrastructure/common/PaymentTypes.kt => business/domain/PaymentDefinitions.kt} (99%) rename service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/{PaymentClientError.kt => UserFacingPaymentErrorCode.kt} (96%) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentClientDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentFindDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayCancelDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayResponseDeserializer.kt rename service/src/main/kotlin/com/sangdol/roomescape/payment/{infrastructure/client/TosspayConfirmDTO.kt => mapper/PaymentClientDTOMappingExtensions.kt} (58%) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentFindDTOMappingExtensions.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index 5bbea39f..f2f0010d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -1,15 +1,19 @@ package com.sangdol.roomescape.payment.business import com.sangdol.common.persistence.TransactionExecutionUtil -import com.sangdol.roomescape.payment.business.domain.PaymentClientError +import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode +import com.sangdol.roomescape.payment.dto.PaymentCancelRequest import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse -import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import com.sangdol.roomescape.payment.dto.PaymentCreateResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.dto.PaymentResponse import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.persistence.* -import com.sangdol.roomescape.payment.web.* +import com.sangdol.roomescape.payment.mapper.toResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service @@ -26,7 +30,7 @@ class PaymentService( private val paymentWriter: PaymentWriter, private val transactionExecutionUtil: TransactionExecutionUtil, ) { - fun requestConfirm(request: PaymentConfirmRequest): PaymentClientConfirmResponse { + fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse { try { return paymentClient.confirm(request.paymentKey, request.orderId, request.amount) } catch (e: ExternalPaymentException) { @@ -36,7 +40,7 @@ class PaymentService( PaymentErrorCode.PAYMENT_PROVIDER_ERROR } - val message = if (PaymentClientError.contains(e.errorCode)) { + val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) { "${errorCode.message}(${e.message})" } else { errorCode.message @@ -48,13 +52,13 @@ class PaymentService( fun savePayment( reservationId: Long, - paymentConfirmResponse: PaymentClientConfirmResponse + paymentGatewayResponse: PaymentGatewayResponse ): PaymentCreateResponse { val payment: PaymentEntity = paymentWriter.createPayment( reservationId = reservationId, - paymentClientConfirmResponse = paymentConfirmResponse + paymentGatewayResponse = paymentGatewayResponse ) - val detail: PaymentDetailEntity = paymentWriter.createDetail(paymentConfirmResponse, payment.id) + val detail: PaymentDetailEntity = paymentWriter.createDetail(paymentGatewayResponse, payment.id) return PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) } @@ -62,7 +66,7 @@ class PaymentService( fun cancel(userId: Long, request: PaymentCancelRequest) { val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId) - val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel( + val clientCancelResponse: PaymentGatewayCancelResponse = paymentClient.cancel( paymentKey = payment.paymentKey, amount = payment.totalAmount, cancelReason = request.cancelReason @@ -81,16 +85,16 @@ class PaymentService( } @Transactional(readOnly = true) - fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? { + fun findDetailByReservationId(reservationId: Long): PaymentResponse? { log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" } val payment: PaymentEntity? = findByReservationIdOrNull(reservationId) val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) } val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) } - return payment?.toDetailResponse( - detail = paymentDetail?.toPaymentDetailResponse(), - cancel = cancelDetail?.toCancelDetailResponse() + return payment?.toResponse( + detail = paymentDetail?.toResponse(), + cancel = cancelDetail?.toResponse() ) } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt index a8f14a9f..948e9b4b 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt @@ -3,8 +3,13 @@ package com.sangdol.roomescape.payment.business import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.client.* -import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.mapper.toCardDetailEntity +import com.sangdol.roomescape.payment.mapper.toEasypayPrepaidDetailEntity +import com.sangdol.roomescape.payment.mapper.toEntity +import com.sangdol.roomescape.payment.mapper.toTransferDetailEntity import com.sangdol.roomescape.payment.infrastructure.persistence.* import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging @@ -23,31 +28,31 @@ class PaymentWriter( fun createPayment( reservationId: Long, - paymentClientConfirmResponse: PaymentClientConfirmResponse + paymentGatewayResponse: PaymentGatewayResponse ): PaymentEntity { - log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" } + log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentGatewayResponse.paymentKey}" } - return paymentClientConfirmResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also { + return paymentGatewayResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also { paymentRepository.save(it) log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" } } } fun createDetail( - paymentResponse: PaymentClientConfirmResponse, + paymentGatewayResponse: PaymentGatewayResponse, paymentId: Long, ): PaymentDetailEntity { - val method: PaymentMethod = paymentResponse.method + val method: PaymentMethod = paymentGatewayResponse.method val id = idGenerator.create() if (method == PaymentMethod.TRANSFER) { - return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId)) + return paymentDetailRepository.save(paymentGatewayResponse.toTransferDetailEntity(id, paymentId)) } - if (method == PaymentMethod.EASY_PAY && paymentResponse.card == null) { - return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId)) + if (method == PaymentMethod.EASY_PAY && paymentGatewayResponse.card == null) { + return paymentDetailRepository.save(paymentGatewayResponse.toEasypayPrepaidDetailEntity(id, paymentId)) } - if (paymentResponse.card != null) { - return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId)) + if (paymentGatewayResponse.card != null) { + return paymentDetailRepository.save(paymentGatewayResponse.toCardDetailEntity(id, paymentId)) } throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) } @@ -56,7 +61,7 @@ class PaymentWriter( userId: Long, payment: PaymentEntity, requestedAt: Instant, - cancelResponse: PaymentClientCancelResponse + cancelResponse: PaymentGatewayCancelResponse ): CanceledPaymentEntity { log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/common/PaymentTypes.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentDefinitions.kt similarity index 99% rename from service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/common/PaymentTypes.kt rename to service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentDefinitions.kt index 5fd64a1d..c642d12c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/common/PaymentTypes.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentDefinitions.kt @@ -1,4 +1,4 @@ -package com.sangdol.roomescape.payment.infrastructure.common +package com.sangdol.roomescape.payment.business.domain import com.fasterxml.jackson.annotation.JsonCreator import com.sangdol.roomescape.payment.exception.PaymentErrorCode diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentClientError.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/UserFacingPaymentErrorCode.kt similarity index 96% rename from service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentClientError.kt rename to service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/UserFacingPaymentErrorCode.kt index fb064ad1..346ba486 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentClientError.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/UserFacingPaymentErrorCode.kt @@ -1,6 +1,6 @@ package com.sangdol.roomescape.payment.business.domain -enum class PaymentClientError { +enum class UserFacingPaymentErrorCode { ALREADY_PROCESSED_PAYMENT, EXCEED_MAX_CARD_INSTALLMENT_PLAN, NOT_ALLOWED_POINT_USE, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt index c79b1cb7..5f7160b8 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt @@ -4,9 +4,9 @@ import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.common.types.CurrentUserContext -import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse -import com.sangdol.roomescape.payment.web.PaymentCancelRequest -import com.sangdol.roomescape.payment.web.PaymentConfirmRequest +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.dto.PaymentCancelRequest +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses @@ -21,7 +21,7 @@ interface PaymentAPI { @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) fun confirmPayment( @Valid @RequestBody request: PaymentConfirmRequest - ): ResponseEntity> + ): ResponseEntity> @Operation(summary = "결제 취소") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentClientDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentClientDTO.kt new file mode 100644 index 00000000..87670ff9 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentClientDTO.kt @@ -0,0 +1,59 @@ +package com.sangdol.roomescape.payment.dto + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.sangdol.roomescape.payment.business.domain.* +import com.sangdol.roomescape.payment.infrastructure.client.CancelDetailDeserializer +import java.time.OffsetDateTime + +data class PaymentGatewayResponse( + val paymentKey: String, + val orderId: String, + val type: PaymentType, + val status: PaymentStatus, + val totalAmount: Int, + val vat: Int, + val suppliedAmount: Int, + val method: PaymentMethod, + val card: CardDetailResponse?, + val easyPay: EasyPayDetailResponse?, + val transfer: TransferDetailResponse?, + val requestedAt: OffsetDateTime, + val approvedAt: OffsetDateTime, +) + +data class PaymentGatewayCancelResponse( + val status: PaymentStatus, + @JsonDeserialize(using = CancelDetailDeserializer::class) + val cancels: CancelDetail, +) + +data class CardDetailResponse( + val issuerCode: CardIssuerCode, + val number: String, + val amount: Int, + val cardType: CardType, + val ownerType: CardOwnerType, + val isInterestFree: Boolean, + val approveNo: String, + val installmentPlanMonths: Int +) + +data class EasyPayDetailResponse( + val provider: EasyPayCompanyCode, + val amount: Int, + val discountAmount: Int, +) + +data class TransferDetailResponse( + val bankCode: BankCode, + val settlementStatus: String, +) + +data class CancelDetail( + val cancelAmount: Int, + val cardDiscountAmount: Int, + val transferDiscountAmount: Int, + val easyPayDiscountAmount: Int, + val canceledAt: OffsetDateTime, + val cancelReason: String +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentFindDTO.kt new file mode 100644 index 00000000..504662ac --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentFindDTO.kt @@ -0,0 +1,49 @@ +package com.sangdol.roomescape.payment.dto + +import com.sangdol.roomescape.payment.business.domain.PaymentStatus +import java.time.Instant + +data class PaymentResponse( + val orderId: String, + val totalAmount: Int, + val method: String, + val status: PaymentStatus, + val requestedAt: Instant, + val approvedAt: Instant, + val detail: PaymentDetailResponse?, + val cancel: PaymentCancelDetailResponse?, +) + +sealed class PaymentDetailResponse { + data class CardDetailResponse( + val type: String = "CARD", + val issuerCode: String, + val cardType: String, + val ownerType: String, + val cardNumber: String, + val amount: Int, + val approvalNumber: String, + val installmentPlanMonths: Int, + val easypayProviderName: String?, + val easypayDiscountAmount: Int?, + ) : PaymentDetailResponse() + + data class BankTransferDetailResponse( + val type: String = "BANK_TRANSFER", + val bankName: String, + ) : PaymentDetailResponse() + + data class EasyPayPrepaidDetailResponse( + val type: String = "EASYPAY_PREPAID", + val providerName: String, + val amount: Int, + val discountAmount: Int, + ) : PaymentDetailResponse() +} + +data class PaymentCancelDetailResponse( + val cancellationRequestedAt: Instant, + val cancellationApprovedAt: Instant?, + val cancelReason: String, + val canceledBy: Long, +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt new file mode 100644 index 00000000..2d89c07c --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt @@ -0,0 +1,20 @@ +package com.sangdol.roomescape.payment.dto + +import java.time.Instant + +data class PaymentConfirmRequest( + val paymentKey: String, + val orderId: String, + val amount: Int, +) + +data class PaymentCreateResponse( + val paymentId: Long, + val detailId: Long +) + +data class PaymentCancelRequest( + val reservationId: Long, + val cancelReason: String, + val requestedAt: Instant = Instant.now() +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayCancelDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayCancelDTO.kt deleted file mode 100644 index 2ba4f6a1..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayCancelDTO.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.sangdol.roomescape.payment.infrastructure.client - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus -import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity -import java.time.Instant -import java.time.OffsetDateTime - -data class PaymentClientCancelResponse( - val status: PaymentStatus, - @JsonDeserialize(using = CancelDetailDeserializer::class) - val cancels: CancelDetail, -) - -data class CancelDetail( - val cancelAmount: Int, - val cardDiscountAmount: Int, - val transferDiscountAmount: Int, - val easyPayDiscountAmount: Int, - val canceledAt: OffsetDateTime, - val cancelReason: String -) - -fun CancelDetail.toEntity( - id: Long, - paymentId: Long, - canceledBy: Long, - cancelRequestedAt: Instant -) = CanceledPaymentEntity( - id = id, - canceledAt = this.canceledAt.toInstant(), - requestedAt = cancelRequestedAt, - paymentId = paymentId, - canceledBy = canceledBy, - cancelReason = this.cancelReason, - cancelAmount = this.cancelAmount, - cardDiscountAmount = this.cardDiscountAmount, - transferDiscountAmount = this.transferDiscountAmount, - easypayDiscountAmount = this.easyPayDiscountAmount -) - -class CancelDetailDeserializer : com.fasterxml.jackson.databind.JsonDeserializer() { - override fun deserialize( - p: JsonParser, - ctxt: DeserializationContext - ): CancelDetail? { - val node: JsonNode = p.codec.readTree(p) ?: return null - - val targetNode = when { - node.isArray && !node.isEmpty -> node[0] - node.isObject -> node - else -> return null - } - - return CancelDetail( - cancelAmount = targetNode.get("cancelAmount").asInt(), - cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(), - transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(), - easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(), - canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()), - cancelReason = targetNode.get("cancelReason").asText() - ) - } -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt index 8840bd20..f296fc38 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt @@ -1,6 +1,8 @@ package com.sangdol.roomescape.payment.infrastructure.client import com.fasterxml.jackson.databind.ObjectMapper +import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException @@ -29,7 +31,7 @@ class TosspayClient( paymentKey: String, orderId: String, amount: Int, - ): PaymentClientConfirmResponse { + ): PaymentGatewayResponse { val startTime = System.currentTimeMillis() log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" } @@ -43,7 +45,7 @@ class TosspayClient( paymentKey: String, amount: Int, cancelReason: String - ): PaymentClientCancelResponse { + ): PaymentGatewayCancelResponse { val startTime = System.currentTimeMillis() log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" } @@ -63,7 +65,7 @@ private class ConfirmClient( private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper) - fun request(paymentKey: String, orderId: String, amount: Int): PaymentClientConfirmResponse { + fun request(paymentKey: String, orderId: String, amount: Int): PaymentGatewayResponse { val response = client.post() .uri(CONFIRM_URI) .contentType(MediaType.APPLICATION_JSON) @@ -84,7 +86,7 @@ private class ConfirmClient( log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" } - return objectMapper.readValue(response, PaymentClientConfirmResponse::class.java) + return objectMapper.readValue(response, PaymentGatewayResponse::class.java) } } @@ -102,7 +104,7 @@ private class CancelClient( paymentKey: String, amount: Int, cancelReason: String - ): PaymentClientCancelResponse { + ): PaymentGatewayCancelResponse { val response = client.post() .uri(CANCEL_URI, paymentKey) .body( @@ -120,7 +122,7 @@ private class CancelClient( } log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" } - return objectMapper.readValue(response, PaymentClientCancelResponse::class.java) + return objectMapper.readValue(response, PaymentGatewayCancelResponse::class.java) } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayResponseDeserializer.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayResponseDeserializer.kt new file mode 100644 index 00000000..1ff0d091 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayResponseDeserializer.kt @@ -0,0 +1,32 @@ +package com.sangdol.roomescape.payment.infrastructure.client + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.sangdol.roomescape.payment.dto.CancelDetail +import java.time.OffsetDateTime + +class CancelDetailDeserializer : JsonDeserializer() { + override fun deserialize( + p: JsonParser, + ctxt: DeserializationContext + ): CancelDetail? { + val node: JsonNode = p.codec.readTree(p) ?: return null + + val targetNode = when { + node.isArray && !node.isEmpty -> node[0] + node.isObject -> node + else -> return null + } + + return CancelDetail( + cancelAmount = targetNode.get("cancelAmount").asInt(), + cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(), + transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(), + easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(), + canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()), + cancelReason = targetNode.get("cancelReason").asText() + ) + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentDetailEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentDetailEntity.kt index 0e06f775..666a7ff0 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentDetailEntity.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentDetailEntity.kt @@ -1,7 +1,11 @@ package com.sangdol.roomescape.payment.infrastructure.persistence import com.sangdol.common.persistence.PersistableBaseEntity -import com.sangdol.roomescape.payment.infrastructure.common.* +import com.sangdol.roomescape.payment.business.domain.BankCode +import com.sangdol.roomescape.payment.business.domain.CardIssuerCode +import com.sangdol.roomescape.payment.business.domain.CardOwnerType +import com.sangdol.roomescape.payment.business.domain.CardType +import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode import jakarta.persistence.* @Entity diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentEntity.kt index 1571e15e..9db97096 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentEntity.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/persistence/PaymentEntity.kt @@ -1,9 +1,9 @@ package com.sangdol.roomescape.payment.infrastructure.persistence import com.sangdol.common.persistence.PersistableBaseEntity -import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod -import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus -import com.sangdol.roomescape.payment.infrastructure.common.PaymentType +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentStatus +import com.sangdol.roomescape.payment.business.domain.PaymentType import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayConfirmDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt similarity index 58% rename from service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayConfirmDTO.kt rename to service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt index 55258c22..cc2979a8 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayConfirmDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt @@ -1,31 +1,13 @@ -package com.sangdol.roomescape.payment.infrastructure.client +package com.sangdol.roomescape.payment.mapper +import com.sangdol.roomescape.payment.dto.CancelDetail +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.common.* -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity -import java.time.OffsetDateTime +import com.sangdol.roomescape.payment.infrastructure.persistence.* +import java.time.Instant -data class PaymentClientConfirmResponse( - val paymentKey: String, - val orderId: String, - val type: PaymentType, - val status: PaymentStatus, - val totalAmount: Int, - val vat: Int, - val suppliedAmount: Int, - val method: PaymentMethod, - val card: CardDetail?, - val easyPay: EasyPayDetail?, - val transfer: TransferDetail?, - val requestedAt: OffsetDateTime, - val approvedAt: OffsetDateTime, -) - -fun PaymentClientConfirmResponse.toEntity( +fun PaymentGatewayResponse.toEntity( id: Long, reservationId: Long, ) = PaymentEntity( @@ -41,18 +23,7 @@ fun PaymentClientConfirmResponse.toEntity( status = this.status, ) -data class CardDetail( - val issuerCode: CardIssuerCode, - val number: String, - val amount: Int, - val cardType: CardType, - val ownerType: CardOwnerType, - val isInterestFree: Boolean, - val approveNo: String, - val installmentPlanMonths: Int -) - -fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity { +fun PaymentGatewayResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity { val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) return PaymentCardDetailEntity( @@ -73,13 +44,7 @@ fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): ) } -data class EasyPayDetail( - val provider: EasyPayCompanyCode, - val amount: Int, - val discountAmount: Int, -) - -fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity( +fun PaymentGatewayResponse.toEasypayPrepaidDetailEntity( id: Long, paymentId: Long ): PaymentEasypayPrepaidDetailEntity { @@ -96,12 +61,7 @@ fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity( ) } -data class TransferDetail( - val bankCode: BankCode, - val settlementStatus: String, -) - -fun PaymentClientConfirmResponse.toTransferDetailEntity( +fun PaymentGatewayResponse.toTransferDetailEntity( id: Long, paymentId: Long ): PaymentBankTransferDetailEntity { @@ -116,3 +76,21 @@ fun PaymentClientConfirmResponse.toTransferDetailEntity( settlementStatus = transferDetail.settlementStatus ) } + +fun CancelDetail.toEntity( + id: Long, + paymentId: Long, + canceledBy: Long, + cancelRequestedAt: Instant +) = CanceledPaymentEntity( + id = id, + canceledAt = this.canceledAt.toInstant(), + requestedAt = cancelRequestedAt, + paymentId = paymentId, + canceledBy = canceledBy, + cancelReason = this.cancelReason, + cancelAmount = this.cancelAmount, + cardDiscountAmount = this.cardDiscountAmount, + transferDiscountAmount = this.transferDiscountAmount, + easypayDiscountAmount = this.easyPayDiscountAmount +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentFindDTOMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentFindDTOMappingExtensions.kt new file mode 100644 index 00000000..a95ec88d --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentFindDTOMappingExtensions.kt @@ -0,0 +1,70 @@ +package com.sangdol.roomescape.payment.mapper + +import com.sangdol.roomescape.payment.dto.PaymentCancelDetailResponse +import com.sangdol.roomescape.payment.dto.PaymentDetailResponse +import com.sangdol.roomescape.payment.dto.PaymentResponse +import com.sangdol.roomescape.payment.exception.PaymentErrorCode +import com.sangdol.roomescape.payment.exception.PaymentException +import com.sangdol.roomescape.payment.infrastructure.persistence.* + +fun PaymentEntity.toResponse( + detail: PaymentDetailResponse?, + cancel: PaymentCancelDetailResponse? +): PaymentResponse { + return PaymentResponse( + orderId = this.orderId, + totalAmount = this.totalAmount, + method = this.method.koreanName, + status = this.status, + requestedAt = this.requestedAt, + approvedAt = this.approvedAt, + detail = detail, + cancel = cancel + ) +} + +fun PaymentDetailEntity.toResponse(): PaymentDetailResponse { + return when (this) { + is PaymentCardDetailEntity -> this.toResponse() + is PaymentBankTransferDetailEntity -> this.toResponse() + is PaymentEasypayPrepaidDetailEntity -> this.toResponse() + else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) + } +} + +fun PaymentCardDetailEntity.toResponse(): PaymentDetailResponse.CardDetailResponse { + return PaymentDetailResponse.CardDetailResponse( + issuerCode = this.issuerCode.koreanName, + cardType = this.cardType.koreanName, + ownerType = this.ownerType.koreanName, + cardNumber = this.cardNumber, + amount = this.amount, + approvalNumber = this.approvalNumber, + installmentPlanMonths = this.installmentPlanMonths, + easypayProviderName = this.easypayProviderCode?.koreanName, + easypayDiscountAmount = this.easypayDiscountAmount + ) +} + +fun PaymentBankTransferDetailEntity.toResponse(): PaymentDetailResponse.BankTransferDetailResponse { + return PaymentDetailResponse.BankTransferDetailResponse( + bankName = this.bankCode.koreanName + ) +} + +fun PaymentEasypayPrepaidDetailEntity.toResponse(): PaymentDetailResponse.EasyPayPrepaidDetailResponse { + return PaymentDetailResponse.EasyPayPrepaidDetailResponse( + providerName = this.easypayProviderCode.koreanName, + amount = this.amount, + discountAmount = this.discountAmount + ) +} + +fun CanceledPaymentEntity.toResponse(): PaymentCancelDetailResponse { + return PaymentCancelDetailResponse( + cancellationRequestedAt = this.requestedAt, + cancellationApprovedAt = this.canceledAt, + cancelReason = this.cancelReason, + canceledBy = this.canceledBy + ) +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt index e4c1e6f1..9f2f36b2 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt @@ -5,7 +5,9 @@ import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.docs.PaymentAPI -import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse +import com.sangdol.roomescape.payment.dto.PaymentCancelRequest +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -19,7 +21,7 @@ class PaymentController( @PostMapping("/confirm") override fun confirmPayment( @Valid @RequestBody request: PaymentConfirmRequest - ): ResponseEntity> { + ): ResponseEntity> { val response = paymentService.requestConfirm(request) return ResponseEntity.ok(CommonApiResponse(response)) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt deleted file mode 100644 index bb94d916..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentDTO.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.sangdol.roomescape.payment.web - -import com.sangdol.roomescape.payment.exception.PaymentErrorCode -import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus -import com.sangdol.roomescape.payment.infrastructure.common.PaymentType -import com.sangdol.roomescape.payment.infrastructure.persistence.* -import com.sangdol.roomescape.payment.web.PaymentDetailResponse.* -import java.time.Instant - -data class PaymentConfirmRequest( - val paymentKey: String, - val orderId: String, - val amount: Int, -) - -data class PaymentCreateResponse( - val paymentId: Long, - val detailId: Long -) - -data class PaymentCancelRequest( - val reservationId: Long, - val cancelReason: String, - val requestedAt: Instant = Instant.now() -) - -data class PaymentWithDetailResponse( - val orderId: String, - val totalAmount: Int, - val method: String, - val status: PaymentStatus, - val requestedAt: Instant, - val approvedAt: Instant, - val detail: PaymentDetailResponse?, - val cancel: PaymentCancelDetailResponse?, -) - -fun PaymentEntity.toDetailResponse( - detail: PaymentDetailResponse?, - cancel: PaymentCancelDetailResponse? -): PaymentWithDetailResponse { - return PaymentWithDetailResponse( - orderId = this.orderId, - totalAmount = this.totalAmount, - method = this.method.koreanName, - status = this.status, - requestedAt = this.requestedAt, - approvedAt = this.approvedAt, - detail = detail, - cancel = cancel - ) -} - -sealed class PaymentDetailResponse { - - data class CardDetailResponse( - val type: String = "CARD", - val issuerCode: String, - val cardType: String, - val ownerType: String, - val cardNumber: String, - val amount: Int, - val approvalNumber: String, - val installmentPlanMonths: Int, - val easypayProviderName: String?, - val easypayDiscountAmount: Int?, - ) : PaymentDetailResponse() - - data class BankTransferDetailResponse( - val type: String = "BANK_TRANSFER", - val bankName: String, - ) : PaymentDetailResponse() - - data class EasyPayPrepaidDetailResponse( - val type: String = "EASYPAY_PREPAID", - val providerName: String, - val amount: Int, - val discountAmount: Int, - ) : PaymentDetailResponse() -} - -fun PaymentDetailEntity.toPaymentDetailResponse(): PaymentDetailResponse { - return when (this) { - is PaymentCardDetailEntity -> this.toCardDetailResponse() - is PaymentBankTransferDetailEntity -> this.toBankTransferDetailResponse() - is PaymentEasypayPrepaidDetailEntity -> this.toEasyPayPrepaidDetailResponse() - else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) - } -} - -fun PaymentCardDetailEntity.toCardDetailResponse(): CardDetailResponse { - return CardDetailResponse( - issuerCode = this.issuerCode.koreanName, - cardType = this.cardType.koreanName, - ownerType = this.ownerType.koreanName, - cardNumber = this.cardNumber, - amount = this.amount, - approvalNumber = this.approvalNumber, - installmentPlanMonths = this.installmentPlanMonths, - easypayProviderName = this.easypayProviderCode?.koreanName, - easypayDiscountAmount = this.easypayDiscountAmount - ) -} - -fun PaymentBankTransferDetailEntity.toBankTransferDetailResponse(): BankTransferDetailResponse { - return BankTransferDetailResponse( - bankName = this.bankCode.koreanName - ) -} - -fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayPrepaidDetailResponse { - return EasyPayPrepaidDetailResponse( - providerName = this.easypayProviderCode.koreanName, - amount = this.amount, - discountAmount = this.discountAmount - ) -} - -data class PaymentCancelDetailResponse( - val cancellationRequestedAt: Instant, - val cancellationApprovedAt: Instant?, - val cancelReason: String, - val canceledBy: Long, -) - -fun CanceledPaymentEntity.toCancelDetailResponse(): PaymentCancelDetailResponse { - return PaymentCancelDetailResponse( - cancellationRequestedAt = this.requestedAt, - cancellationApprovedAt = this.canceledAt, - cancelReason = this.cancelReason, - canceledBy = this.canceledBy - ) -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index a7537a06..6be365ad 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -3,7 +3,7 @@ package com.sangdol.roomescape.reservation.business import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.payment.business.PaymentService -import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse +import com.sangdol.roomescape.payment.dto.PaymentResponse import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest @@ -132,7 +132,7 @@ class ReservationService( val reservation: ReservationEntity = findOrThrow(id) val user: UserContactResponse = userService.findContactById(reservation.userId) - val paymentDetail: PaymentWithDetailResponse? = paymentService.findDetailByReservationId(id) + val paymentDetail: PaymentResponse? = paymentService.findDetailByReservationId(id) return reservation.toAdditionalResponse( user = user, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt index 380c8e4c..7244a70f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt @@ -1,6 +1,6 @@ package com.sangdol.roomescape.reservation.dto -import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse +import com.sangdol.roomescape.payment.dto.PaymentResponse import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.user.dto.UserContactResponse import java.time.Instant @@ -22,7 +22,7 @@ data class ReservationAdditionalResponse( val reserver: ReserverInfo, val user: UserContactResponse, val applicationDateTime: Instant, - val payment: PaymentWithDetailResponse?, + val payment: PaymentResponse?, ) data class ReserverInfo( diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt index 1699b2a4..052fffd4 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt @@ -1,6 +1,6 @@ package com.sangdol.roomescape.reservation.mapper -import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse +import com.sangdol.roomescape.payment.dto.PaymentResponse import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse import com.sangdol.roomescape.reservation.dto.ReservationOverviewResponse @@ -40,7 +40,7 @@ fun ReservationEntity.toOverviewResponse( fun ReservationEntity.toAdditionalResponse( user: UserContactResponse, - payment: PaymentWithDetailResponse?, + payment: PaymentResponse?, ): ReservationAdditionalResponse { return ReservationAdditionalResponse( id = this.id, diff --git a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt index e4b1f55c..4faa0a6e 100644 --- a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt @@ -6,7 +6,14 @@ import com.sangdol.common.utils.KoreaDateTime import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType -import com.sangdol.roomescape.payment.infrastructure.common.* +import com.sangdol.roomescape.payment.business.domain.BankCode +import com.sangdol.roomescape.payment.business.domain.CardIssuerCode +import com.sangdol.roomescape.payment.business.domain.CardOwnerType +import com.sangdol.roomescape.payment.business.domain.CardType +import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentStatus +import com.sangdol.roomescape.payment.business.domain.PaymentType import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt index c18fce21..c980ad28 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt @@ -11,7 +11,7 @@ //import com.sangdol.roomescape.payment.infrastructure.client.TransferDetail //import com.sangdol.roomescape.payment.infrastructure.common.* //import com.sangdol.roomescape.payment.infrastructure.persistence.* -//import com.sangdol.roomescape.payment.web.PaymentConfirmRequest +//import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest //import com.sangdol.roomescape.payment.web.PaymentCreateResponse //import com.sangdol.roomescape.supports.* //import io.kotest.matchers.shouldBe diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentTypeTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentTypeTest.kt index 5f3c95ff..ca6ca244 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentTypeTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentTypeTest.kt @@ -1,8 +1,15 @@ package com.sangdol.roomescape.payment +import com.sangdol.roomescape.payment.business.domain.BankCode +import com.sangdol.roomescape.payment.business.domain.CardIssuerCode +import com.sangdol.roomescape.payment.business.domain.CardOwnerType +import com.sangdol.roomescape.payment.business.domain.CardType +import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentStatus +import com.sangdol.roomescape.payment.business.domain.PaymentType import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.common.* import io.kotest.assertions.assertSoftly import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt index c9c54396..84c6cbec 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/TosspayClientTest.kt @@ -2,12 +2,10 @@ package com.sangdol.roomescape.payment import com.ninjasquad.springmockk.MockkBean import com.sangdol.roomescape.payment.exception.ExternalPaymentException -import com.sangdol.roomescape.payment.exception.PaymentErrorCode -import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse -import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient -import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus +import com.sangdol.roomescape.payment.business.domain.PaymentStatus import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec @@ -116,7 +114,7 @@ class TosspayClientTest( .createResponse(it) } - val cancelResponse: PaymentClientCancelResponse = client.cancel( + val cancelResponse: PaymentGatewayCancelResponse = client.cancel( SampleTosspayConstant.PAYMENT_KEY, SampleTosspayConstant.AMOUNT, SampleTosspayConstant.CANCEL_REASON @@ -162,13 +160,13 @@ class TosspayClientTest( } fun runConfirmTest() { - val paymentResponse: PaymentClientConfirmResponse = client.confirm( + val paymentGatewayResponse: PaymentGatewayResponse = client.confirm( SampleTosspayConstant.PAYMENT_KEY, SampleTosspayConstant.ORDER_ID, SampleTosspayConstant.AMOUNT ) - assertSoftly(paymentResponse) { + assertSoftly(paymentGatewayResponse) { this.paymentKey shouldBe SampleTosspayConstant.PAYMENT_KEY this.totalAmount shouldBe SampleTosspayConstant.AMOUNT } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt index 4f8fc9d7..d29cc9d4 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -5,9 +5,9 @@ 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.payment.infrastructure.common.BankCode -import com.sangdol.roomescape.payment.infrastructure.common.CardIssuerCode -import com.sangdol.roomescape.payment.infrastructure.common.EasyPayCompanyCode +import com.sangdol.roomescape.payment.business.domain.BankCode +import com.sangdol.roomescape.payment.business.domain.CardIssuerCode +import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.infrastructure.persistence.CanceledReservationRepository diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index 3d4e17b4..e40bb5a8 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -1,32 +1,27 @@ package com.sangdol.roomescape.supports import com.sangdol.roomescape.payment.business.PaymentWriter -import com.sangdol.roomescape.payment.infrastructure.client.CardDetail -import com.sangdol.roomescape.payment.infrastructure.client.EasyPayDetail -import com.sangdol.roomescape.payment.infrastructure.client.TransferDetail -import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.dto.* import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository -import com.sangdol.roomescape.payment.web.PaymentConfirmRequest -import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse -import com.sangdol.roomescape.payment.web.toDetailResponse -import com.sangdol.roomescape.payment.web.toPaymentDetailResponse +import com.sangdol.roomescape.payment.mapper.toResponse +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest 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.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.mapper.toEntity +import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest 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.schedule.dto.ScheduleCreateRequest import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus +import com.sangdol.roomescape.theme.dto.ThemeCreateRequest import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.theme.dto.ThemeCreateRequest import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import org.springframework.data.repository.findByIdOrNull @@ -164,10 +159,10 @@ class DummyInitializer( fun createPayment( reservationId: Long, request: PaymentConfirmRequest = PaymentFixture.confirmRequest, - cardDetail: CardDetail? = null, - easyPayDetail: EasyPayDetail? = null, - transferDetail: TransferDetail? = null, - ): PaymentWithDetailResponse { + cardDetail: CardDetailResponse? = null, + easyPayDetail: EasyPayDetailResponse? = null, + transferDetail: TransferDetailResponse? = null, + ): PaymentResponse { val method = if (easyPayDetail != null) { PaymentMethod.EASY_PAY } else if (cardDetail != null) { @@ -190,12 +185,12 @@ class DummyInitializer( val payment = paymentWriter.createPayment( reservationId = reservationId, - paymentClientConfirmResponse = clientConfirmResponse + paymentGatewayResponse = clientConfirmResponse ) val detail = paymentWriter.createDetail(clientConfirmResponse, payment.id) - return payment.toDetailResponse(detail = detail.toPaymentDetailResponse(), cancel = null) + return payment.toResponse(detail = detail.toResponse(), cancel = null) } fun cancelPayment( diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index b7fe117e..821517f8 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -6,10 +6,22 @@ import com.sangdol.common.utils.KoreaDateTime import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType -import com.sangdol.roomescape.payment.infrastructure.client.* -import com.sangdol.roomescape.payment.infrastructure.common.* -import com.sangdol.roomescape.payment.web.PaymentCancelRequest -import com.sangdol.roomescape.payment.web.PaymentConfirmRequest +import com.sangdol.roomescape.payment.business.domain.BankCode +import com.sangdol.roomescape.payment.business.domain.CardIssuerCode +import com.sangdol.roomescape.payment.business.domain.CardOwnerType +import com.sangdol.roomescape.payment.business.domain.CardType +import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentStatus +import com.sangdol.roomescape.payment.business.domain.PaymentType +import com.sangdol.roomescape.payment.dto.CancelDetail +import com.sangdol.roomescape.payment.dto.CardDetailResponse +import com.sangdol.roomescape.payment.dto.EasyPayDetailResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.dto.TransferDetailResponse +import com.sangdol.roomescape.payment.dto.PaymentCancelRequest +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory @@ -254,7 +266,7 @@ object PaymentFixture { cardType: CardType = CardType.entries.random(), ownerType: CardOwnerType = CardOwnerType.entries.random(), installmentPlanMonths: Int = 0, - ): CardDetail = CardDetail( + ): CardDetailResponse = CardDetailResponse( issuerCode = issuerCode, number = "${(400000..500000).random()}*********", amount = amount, @@ -269,12 +281,12 @@ object PaymentFixture { amount: Int, provider: EasyPayCompanyCode = EasyPayCompanyCode.entries.random(), discountAmount: Int = 0 - ): EasyPayDetail = EasyPayDetail(provider, amount, discountAmount) + ): EasyPayDetailResponse = EasyPayDetailResponse(provider, amount, discountAmount) fun transferDetail( bankCode: BankCode = BankCode.entries.random(), settlementStatus: String = "COMPLETED" - ): TransferDetail = TransferDetail( + ): TransferDetailResponse = TransferDetailResponse( bankCode = bankCode, settlementStatus = settlementStatus ) @@ -283,11 +295,11 @@ object PaymentFixture { paymentKey: String, amount: Int, method: PaymentMethod, - cardDetail: CardDetail?, - easyPayDetail: EasyPayDetail?, - transferDetail: TransferDetail?, + cardDetail: CardDetailResponse?, + easyPayDetail: EasyPayDetailResponse?, + transferDetail: TransferDetailResponse?, orderId: String = randomString(25), - ) = PaymentClientConfirmResponse( + ) = PaymentGatewayResponse( paymentKey = paymentKey, status = PaymentStatus.DONE, orderId = orderId, @@ -309,7 +321,7 @@ object PaymentFixture { transferDiscountAmount: Int = 0, easypayDiscountAmount: Int = 0, cancelReason: String = "cancelReason" - ) = PaymentClientCancelResponse( + ) = PaymentGatewayCancelResponse( status = PaymentStatus.CANCELED, cancels = CancelDetail( cancelAmount = amount, -- 2.47.2 From 979623a67061fd45514d7b4d7af3f48fc0a827f8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:17:01 +0900 Subject: [PATCH 25/45] =?UTF-8?q?refactor:=20GlobalExceptionHandler?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=8A=94=20INFO=20=EB=A1=9C=EA=B7=B8=EB=A1=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/web/exception/GlobalExceptionhandler.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt b/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt index cab24523..bba98c3e 100644 --- a/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt +++ b/common/web/src/main/kotlin/com/sangdol/common/web/exception/GlobalExceptionhandler.kt @@ -30,7 +30,7 @@ class GlobalExceptionHandler( val httpStatus: HttpStatus = errorCode.httpStatus val errorResponse = CommonErrorResponse(errorCode) - logException(servletRequest, httpStatus, errorResponse, e) + log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) } return ResponseEntity .status(httpStatus.value()) @@ -56,7 +56,7 @@ class GlobalExceptionHandler( val httpStatus: HttpStatus = errorCode.httpStatus val errorResponse = CommonErrorResponse(errorCode) - logException(servletRequest, httpStatus, errorResponse, e) + log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) } return ResponseEntity .status(httpStatus.value()) @@ -74,28 +74,26 @@ class GlobalExceptionHandler( val httpStatus: HttpStatus = errorCode.httpStatus val errorResponse = CommonErrorResponse(errorCode) - logException(servletRequest, httpStatus, errorResponse, e) + log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) } return ResponseEntity .status(httpStatus.value()) .body(errorResponse) } - private fun logException( + private fun convertExceptionLogMessage( servletRequest: HttpServletRequest, httpStatus: HttpStatus, errorResponse: CommonErrorResponse, exception: Exception - ) { + ): String { val actualException: Exception? = if (errorResponse.message == exception.message) null else exception - val logMessage = messageConverter.convertToErrorResponseMessage( + return messageConverter.convertToErrorResponseMessage( servletRequest = servletRequest, httpStatus = httpStatus, responseBody = errorResponse, exception = actualException ) - - log.warn { logMessage } } } -- 2.47.2 From c4cd1681751c11d4c9f8379767c738a07106b0da Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:17:46 +0900 Subject: [PATCH 26/45] =?UTF-8?q?refactor:=20PaymentService=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=A9=EB=B2=95=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EB=B3=84=EB=8F=84=EC=9D=98=20ExceptionHandler=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/PaymentAttemptRepository.kt | 26 + .../roomescape/payment/PaymentAPITest.kt | 757 +++++++++--------- 2 files changed, 405 insertions(+), 378 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt new file mode 100644 index 00000000..3c5e8666 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt @@ -0,0 +1,26 @@ +package com.sangdol.roomescape.order.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface PaymentAttemptRepository: JpaRepository { + + fun countByReservationId(reservationId: Long): Long + + @Query( + """ + SELECT + CASE + WHEN COUNT(pa) > 0 + THEN TRUE + ELSE FALSE + END + FROM + PaymentAttemptEntity pa + WHERE + pa.reservationId = :reservationId + AND pa.result = com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult.SUCCESS + """ + ) + fun isSuccessAttemptExists(reservationId: Long): Boolean +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt index c980ad28..773912ea 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt @@ -1,378 +1,379 @@ -//package com.sangdol.roomescape.payment -// -//import com.ninjasquad.springmockk.MockkBean -//import com.sangdol.common.types.web.HttpStatus -//import com.sangdol.roomescape.auth.exception.AuthErrorCode -//import com.sangdol.roomescape.payment.business.PaymentService -//import com.sangdol.roomescape.payment.exception.PaymentErrorCode -//import com.sangdol.roomescape.payment.infrastructure.client.CardDetail -//import com.sangdol.roomescape.payment.infrastructure.client.EasyPayDetail -//import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient -//import com.sangdol.roomescape.payment.infrastructure.client.TransferDetail -//import com.sangdol.roomescape.payment.infrastructure.common.* -//import com.sangdol.roomescape.payment.infrastructure.persistence.* -//import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest -//import com.sangdol.roomescape.payment.web.PaymentCreateResponse -//import com.sangdol.roomescape.supports.* -//import io.kotest.matchers.shouldBe -//import io.mockk.every -//import org.springframework.data.repository.findByIdOrNull -//import org.springframework.http.HttpMethod -// -//class PaymentAPITest( -// @MockkBean -// private val tosspayClient: TosspayClient, -// private val paymentService: PaymentService, -// private val paymentRepository: PaymentRepository, -// private val paymentDetailRepository: PaymentDetailRepository, -// private val canceledPaymentRepository: CanceledPaymentRepository -//) : FunSpecSpringbootTest() { -// init { -// context("결제를 승인한다.") { -// context("권한이 없으면 접근할 수 없다.") { -// val endpoint = "/payments?reservationId=$INVALID_PK" -// -// test("비회원") { -// runExceptionTest( -// method = HttpMethod.POST, -// endpoint = endpoint, -// expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND -// ) -// } -// -// test("관리자") { -// runExceptionTest( -// token = testAuthUtil.defaultHqAdminLogin().second, -// method = HttpMethod.POST, -// endpoint = endpoint, -// expectedErrorCode = AuthErrorCode.ACCESS_DENIED -// ) -// } -// } -// -// val amount = 100_000 -// context("간편결제 + 카드로 ${amount}원을 결제한다.") { -// context("일시불") { -// test("토스페이 + 토스뱅크카드(신용)") { -// runConfirmTest( -// amount = amount, -// cardDetail = PaymentFixture.cardDetail( -// amount = amount, -// issuerCode = CardIssuerCode.TOSS_BANK, -// cardType = CardType.CREDIT, -// ), -// easyPayDetail = PaymentFixture.easypayDetail( -// amount = 0, -// provider = EasyPayCompanyCode.TOSSPAY -// ) -// ) -// } -// -// test("삼성페이 + 삼성카드(법인)") { -// runConfirmTest( -// amount = amount, -// cardDetail = PaymentFixture.cardDetail( -// amount = amount, -// issuerCode = CardIssuerCode.SAMSUNG, -// cardType = CardType.CREDIT, -// ownerType = CardOwnerType.CORPORATE -// ), -// easyPayDetail = PaymentFixture.easypayDetail( -// amount = 0, -// provider = EasyPayCompanyCode.SAMSUNGPAY -// ) -// ) -// } -// } -// -// context("할부") { -// val installmentPlanMonths = 12 -// test("네이버페이 + 신한카드 / 12개월") { -// runConfirmTest( -// amount = amount, -// cardDetail = PaymentFixture.cardDetail( -// amount = amount, -// issuerCode = CardIssuerCode.SHINHAN, -// installmentPlanMonths = installmentPlanMonths -// ), -// easyPayDetail = PaymentFixture.easypayDetail( -// amount = 0, -// provider = EasyPayCompanyCode.NAVERPAY -// ) -// ) -// } -// } -// -// context("간편결제사 포인트 일부 사용") { -// val point = (amount * 0.1).toInt() -// test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") { -// runConfirmTest( -// amount = amount, -// cardDetail = PaymentFixture.cardDetail( -// amount = (amount - point), -// issuerCode = CardIssuerCode.KOOKMIN, -// cardType = CardType.CHECK -// ), -// easyPayDetail = PaymentFixture.easypayDetail( -// amount = 0, -// provider = EasyPayCompanyCode.TOSSPAY, -// discountAmount = point -// ) -// ) -// } -// } -// } -// -// context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") { -// test("토스페이 + 토스페이머니 / 전액") { -// runConfirmTest( -// easyPayDetail = PaymentFixture.easypayDetail( -// amount = amount, -// provider = EasyPayCompanyCode.TOSSPAY -// ) -// ) -// } -// -// val point = (amount * 0.05).toInt() -// -// test("카카오페이 + 카카오페이머니 / $point 사용") { -// runConfirmTest( -// easyPayDetail = PaymentFixture.easypayDetail( -// amount = (amount - point), -// provider = EasyPayCompanyCode.KAKAOPAY, -// discountAmount = point -// ) -// ) -// } -// } -// -// context("계좌이체로 결제한다.") { -// test("토스뱅크") { -// runConfirmTest( -// transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK) -// ) -// } -// } -// -// context("지원하지 않는 결제수단으로 요청시 실패한다.") { -// val supportedMethod = listOf( -// PaymentMethod.CARD, -// PaymentMethod.EASY_PAY, -// PaymentMethod.TRANSFER, -// ) -// -// PaymentMethod.entries.filter { it !in supportedMethod }.forEach { -// test("결제 수단: ${it.koreanName}") { -// val (user, token) = testAuthUtil.defaultUserLogin() -// val reservation = dummyInitializer.createConfirmReservation(user = user) -// -// val request = PaymentFixture.confirmRequest -// -// every { -// tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) -// } returns PaymentFixture.confirmResponse( -// paymentKey = request.paymentKey, -// amount = request.amount, -// method = it, -// cardDetail = null, -// easyPayDetail = null, -// transferDetail = null, -// ) -// -// runExceptionTest( -// token = token, -// method = HttpMethod.POST, -// endpoint = "/payments?reservationId=${reservation.id}", -// requestBody = PaymentFixture.confirmRequest, -// expectedErrorCode = PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE -// ) -// } -// } -// } -// } -// -// context("결제를 취소한다.") { -// context("권한이 없으면 접근할 수 없다.") { -// val endpoint = "/payments/cancel" -// -// test("비회원") { -// runExceptionTest( -// method = HttpMethod.POST, -// endpoint = endpoint, -// requestBody = PaymentFixture.cancelRequest, -// expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND -// ) -// } -// -// test("관리자") { -// runExceptionTest( -// token = testAuthUtil.defaultHqAdminLogin().second, -// method = HttpMethod.POST, -// endpoint = endpoint, -// requestBody = PaymentFixture.cancelRequest, -// expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND -// ) -// } -// } -// -// test("정상 취소") { -// val (user, token) = testAuthUtil.defaultUserLogin() -// val reservation = dummyInitializer.createConfirmReservation(user = user) -// val confirmRequest = PaymentFixture.confirmRequest -// -// val paymentCreateResponse = createPayment( -// request = confirmRequest, -// reservationId = reservation.id -// ) -// -// every { -// tosspayClient.cancel( -// confirmRequest.paymentKey, -// confirmRequest.amount, -// cancelReason = "cancelReason" -// ) -// } returns PaymentFixture.cancelResponse(confirmRequest.amount) -// -// val requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id) -// -// runTest( -// token = token, -// using = { -// body(requestBody) -// }, -// on = { -// post("/payments/cancel") -// }, -// expect = { -// statusCode(HttpStatus.OK.value()) -// } -// ).also { -// val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) -// ?: throw AssertionError("Unexpected Exception Occurred.") -// val canceledPayment = -// canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) -// ?: throw AssertionError("Unexpected Exception Occurred.") -// -// payment.status shouldBe PaymentStatus.CANCELED -// canceledPayment.paymentId shouldBe payment.id -// canceledPayment.cancelAmount shouldBe payment.totalAmount -// } -// } -// -// test("예약에 대한 결제 정보가 없으면 실패한다.") { -// val (user, token) = testAuthUtil.defaultUserLogin() -// val reservation = dummyInitializer.createConfirmReservation(user = user) -// -// runExceptionTest( -// token = token, -// method = HttpMethod.POST, -// endpoint = "/payments/cancel", -// requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id), -// expectedErrorCode = PaymentErrorCode.PAYMENT_NOT_FOUND -// ) -// } -// } -// } -// -// private fun createPayment( -// request: PaymentConfirmRequest, -// reservationId: Long, -// ): PaymentCreateResponse { -// every { -// tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) -// } returns PaymentFixture.confirmResponse( -// request.paymentKey, -// request.amount, -// method = PaymentMethod.CARD, -// cardDetail = PaymentFixture.cardDetail(request.amount), -// easyPayDetail = null, -// transferDetail = null, -// ) -// -// return paymentService.confirm(reservationId, request) -// } -// -// fun runConfirmTest( -// cardDetail: CardDetail? = null, -// easyPayDetail: EasyPayDetail? = null, -// transferDetail: TransferDetail? = null, -// paymentKey: String = "paymentKey", -// amount: Int = 10000, -// ) { -// val (user, token) = testAuthUtil.defaultUserLogin() -// val reservation = dummyInitializer.createConfirmReservation(user = user) -// val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) -// -// val method = if (easyPayDetail != null) { -// PaymentMethod.EASY_PAY -// } else if (cardDetail != null) { -// PaymentMethod.CARD -// } else if (transferDetail != null) { -// PaymentMethod.TRANSFER -// } else { -// throw AssertionError("결제타입 확인 필요.") -// } -// -// val clientResponse = PaymentFixture.confirmResponse( -// paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail -// ) -// -// every { -// tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) -// } returns clientResponse -// -// runTest( -// token = token, -// using = { -// body(request) -// }, -// on = { -// post("/payments?reservationId=${reservation.id}") -// }, -// expect = { -// statusCode(HttpStatus.OK.value()) -// } -// ).also { -// val createdPayment = paymentRepository.findByIdOrNull(it.extract().path("data.paymentId")) -// ?: throw AssertionError("Unexpected Exception Occurred.") -// val createdPaymentDetail = -// paymentDetailRepository.findByIdOrNull(it.extract().path("data.detailId")) -// ?: throw AssertionError("Unexpected Exception Occurred.") -// -// createdPayment.status shouldBe clientResponse.status -// createdPayment.method shouldBe clientResponse.method -// createdPayment.reservationId shouldBe reservation.id -// -// when (createdPaymentDetail) { -// is PaymentCardDetailEntity -> { -// createdPaymentDetail.issuerCode shouldBe clientResponse.card!!.issuerCode -// createdPaymentDetail.cardType shouldBe clientResponse.card.cardType -// createdPaymentDetail.cardNumber shouldBe clientResponse.card.number -// createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount -// createdPaymentDetail.vat shouldBe clientResponse.vat -// createdPaymentDetail.amount shouldBe (clientResponse.totalAmount - (clientResponse.easyPay?.discountAmount -// ?: 0)) -// clientResponse.easyPay?.let { easypay -> -// createdPaymentDetail.easypayProviderCode shouldBe easypay.provider -// createdPaymentDetail.easypayDiscountAmount shouldBe easypay.discountAmount -// } -// } -// -// is PaymentBankTransferDetailEntity -> { -// createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount -// createdPaymentDetail.vat shouldBe clientResponse.vat -// createdPaymentDetail.bankCode shouldBe clientResponse.transfer!!.bankCode -// createdPaymentDetail.settlementStatus shouldBe clientResponse.transfer.settlementStatus -// } -// -// is PaymentEasypayPrepaidDetailEntity -> { -// createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount -// createdPaymentDetail.vat shouldBe clientResponse.vat -// createdPaymentDetail.easypayProviderCode shouldBe clientResponse.easyPay!!.provider -// createdPaymentDetail.amount shouldBe clientResponse.easyPay.amount -// createdPaymentDetail.discountAmount shouldBe clientResponse.easyPay.discountAmount -// } -// } -// } -// } -//} +package com.sangdol.roomescape.payment + +import com.ninjasquad.springmockk.MockkBean +import com.sangdol.common.types.web.HttpStatus +import com.sangdol.roomescape.auth.exception.AuthErrorCode +import com.sangdol.roomescape.payment.business.PaymentService +import com.sangdol.roomescape.payment.business.domain.* +import com.sangdol.roomescape.payment.dto.* +import com.sangdol.roomescape.payment.exception.ExternalPaymentException +import com.sangdol.roomescape.payment.exception.PaymentErrorCode +import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient +import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.PaymentFixture +import com.sangdol.roomescape.supports.runExceptionTest +import com.sangdol.roomescape.supports.runTest +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod + +class PaymentAPITest( + @MockkBean + private val tosspayClient: TosspayClient, + private val paymentService: PaymentService, + private val paymentRepository: PaymentRepository, + private val canceledPaymentRepository: CanceledPaymentRepository +) : FunSpecSpringbootTest() { + init { + context("결제를 승인한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/payments/confirm" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin().second, + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + + val amount = 100_000 + context("간편결제 + 카드로 ${amount}원을 결제한다.") { + context("일시불") { + test("토스페이 + 토스뱅크카드(신용)") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = amount, + issuerCode = CardIssuerCode.TOSS_BANK, + cardType = CardType.CREDIT, + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.TOSSPAY + ) + ) + } + + test("삼성페이 + 삼성카드(법인)") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = amount, + issuerCode = CardIssuerCode.SAMSUNG, + cardType = CardType.CREDIT, + ownerType = CardOwnerType.CORPORATE + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.SAMSUNGPAY + ) + ) + } + } + + context("할부") { + val installmentPlanMonths = 12 + test("네이버페이 + 신한카드 / 12개월") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = amount, + issuerCode = CardIssuerCode.SHINHAN, + installmentPlanMonths = installmentPlanMonths + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.NAVERPAY + ) + ) + } + } + + context("간편결제사 포인트 일부 사용") { + val point = (amount * 0.1).toInt() + test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = (amount - point), + issuerCode = CardIssuerCode.KOOKMIN, + cardType = CardType.CHECK + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.TOSSPAY, + discountAmount = point + ) + ) + } + } + } + + context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") { + test("토스페이 + 토스페이머니 / 전액") { + runConfirmTest( + easyPayDetail = PaymentFixture.easypayDetail( + amount = amount, + provider = EasyPayCompanyCode.TOSSPAY + ) + ) + } + + val point = (amount * 0.05).toInt() + + test("카카오페이 + 카카오페이머니 / $point 사용") { + runConfirmTest( + easyPayDetail = PaymentFixture.easypayDetail( + amount = (amount - point), + provider = EasyPayCompanyCode.KAKAOPAY, + discountAmount = point + ) + ) + } + } + + context("계좌이체로 결제한다.") { + test("토스뱅크") { + runConfirmTest( + transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK) + ) + } + } + + context("결제 처리중 오류가 발생한다.") { + lateinit var token: String + val commonRequest = PaymentFixture.confirmRequest + + beforeTest { + token = testAuthUtil.defaultUserLogin().second + } + + afterTest { + clearMocks(tosspayClient) + } + + test("예외 코드가 UserFacingPaymentErrorCode에 있으면 결제 실패 메시지를 같이 담는다.") { + val statusCode = HttpStatus.BAD_REQUEST.value() + val message = "거래금액 한도를 초과했습니다." + + every { + tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount) + } throws ExternalPaymentException( + httpStatusCode = statusCode, + errorCode = UserFacingPaymentErrorCode.EXCEED_MAX_AMOUNT.name, + message = message + ) + + runTest( + token = token, + using = { + body(commonRequest) + }, + on = { + post("/payments/confirm") + }, + expect = { + statusCode(statusCode) + body("code", equalTo(PaymentErrorCode.PAYMENT_CLIENT_ERROR.errorCode)) + body("message", containsString(message)) + } + ) + } + + context("예외 코드가 UserFacingPaymentErrorCode에 없으면 Client의 상태 코드에 따라 다르게 처리한다.") { + mapOf( + HttpStatus.BAD_REQUEST.value() to PaymentErrorCode.PAYMENT_CLIENT_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR.value() to PaymentErrorCode.PAYMENT_PROVIDER_ERROR + ).forEach { (statusCode, expectedErrorCode) -> + test("statusCode=${statusCode}") { + val message = "잘못된 시크릿키 연동 정보 입니다." + + every { + tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount) + } throws ExternalPaymentException( + httpStatusCode = statusCode, + errorCode = "INVALID_API_KEY", + message = message + ) + + runTest( + token = token, + using = { + body(commonRequest) + }, + on = { + post("/payments/confirm") + }, + expect = { + statusCode(statusCode) + body("code", equalTo(expectedErrorCode.errorCode)) + body("message", equalTo(expectedErrorCode.message)) + } + ) + } + } + } + } + } + + context("결제를 취소한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/payments/cancel" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = PaymentFixture.cancelRequest, + expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin().second, + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = PaymentFixture.cancelRequest, + expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND + ) + } + } + + test("정상 취소") { + val (user, token) = testAuthUtil.defaultUserLogin() + val reservation = dummyInitializer.createConfirmReservation(user = user) + val confirmRequest = PaymentFixture.confirmRequest + + val paymentCreateResponse = createPayment( + request = confirmRequest, + reservationId = reservation.id + ) + + every { + tosspayClient.cancel( + confirmRequest.paymentKey, + confirmRequest.amount, + cancelReason = "cancelReason" + ) + } returns PaymentFixture.cancelResponse(confirmRequest.amount) + + val requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id) + + runTest( + token = token, + using = { + body(requestBody) + }, + on = { + post("/payments/cancel") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) + ?: throw AssertionError("Unexpected Exception Occurred.") + val canceledPayment = + canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) + ?: throw AssertionError("Unexpected Exception Occurred.") + + payment.status shouldBe PaymentStatus.CANCELED + canceledPayment.paymentId shouldBe payment.id + canceledPayment.cancelAmount shouldBe payment.totalAmount + } + } + + test("예약에 대한 결제 정보가 없으면 실패한다.") { + val (user, token) = testAuthUtil.defaultUserLogin() + val reservation = dummyInitializer.createConfirmReservation(user = user) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/payments/cancel", + requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id), + expectedErrorCode = PaymentErrorCode.PAYMENT_NOT_FOUND + ) + } + } + } + + private fun createPayment( + request: PaymentConfirmRequest, + reservationId: Long, + ): PaymentCreateResponse { + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } returns PaymentFixture.confirmResponse( + request.paymentKey, + request.amount, + method = PaymentMethod.CARD, + cardDetail = PaymentFixture.cardDetail(request.amount), + easyPayDetail = null, + transferDetail = null, + ) + + val paymentResponse = paymentService.requestConfirm(request) + return paymentService.savePayment(reservationId, paymentResponse) + } + + fun runConfirmTest( + cardDetail: CardDetailResponse? = null, + easyPayDetail: EasyPayDetailResponse? = null, + transferDetail: TransferDetailResponse? = null, + paymentKey: String = "paymentKey", + amount: Int = 10000, + ) { + val token = testAuthUtil.defaultUserLogin().second + val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) + + val method = if (easyPayDetail != null) { + PaymentMethod.EASY_PAY + } else if (cardDetail != null) { + PaymentMethod.CARD + } else if (transferDetail != null) { + PaymentMethod.TRANSFER + } else { + throw AssertionError("결제타입 확인 필요.") + } + + val clientResponse = PaymentFixture.confirmResponse( + paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail + ) + + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } returns clientResponse + + runTest( + token = token, + using = { + body(request) + }, + on = { + post("/payments/confirm") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + } +} -- 2.47.2 From 5e77b1cf91cfcf14ff6f308e69fbba6f97661710 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:18:46 +0900 Subject: [PATCH 27/45] =?UTF-8?q?feat:=20ReservationStatus=EC=97=90=20PAYM?= =?UTF-8?q?ENT=5FIN=5FPROGRESS=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/infrastructure/persistence/ReservationEntity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt index 0bccb252..26991305 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt @@ -31,5 +31,5 @@ class ReservationEntity( } enum class ReservationStatus { - PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED + PENDING, PAYMENT_IN_PROGRESS, CONFIRMED, CANCELED, EXPIRED; } -- 2.47.2 From 365a2a37ae6b91dd6f4601bc95e89f294210308f Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:19:39 +0900 Subject: [PATCH 28/45] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A0=20Order=EC=97=90=EC=84=9C=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=AC=20ReservationStateResponse=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20PAYMENT=5FIN=5FPRO?= =?UTF-8?q?GRESS=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationService.kt | 37 +++++++++++++++---- .../reservation/dto/ReservationFindDTO.kt | 7 ++++ .../persistence/ReservationRepository.kt | 6 +++ .../mapper/ReservationMappingExtensions.kt | 8 ++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index 6be365ad..8496801d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -4,17 +4,14 @@ import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.dto.PaymentResponse -import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest -import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse -import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest -import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse -import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse -import com.sangdol.roomescape.reservation.mapper.toEntity -import com.sangdol.roomescape.reservation.mapper.toOverviewResponse -import com.sangdol.roomescape.reservation.mapper.toAdditionalResponse +import com.sangdol.roomescape.reservation.dto.* import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.infrastructure.persistence.* +import com.sangdol.roomescape.reservation.mapper.toAdditionalResponse +import com.sangdol.roomescape.reservation.mapper.toEntity +import com.sangdol.roomescape.reservation.mapper.toOverviewResponse +import com.sangdol.roomescape.reservation.mapper.toStateResponse import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse @@ -142,6 +139,30 @@ class ReservationService( } } + @Transactional(readOnly = true) + fun findStatusWithLock(id: Long): ReservationStateResponse { + log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" } + + return reservationRepository.findByIdForUpdate(id)?.let { + log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" } + it.toStateResponse() + } ?: run { + log.warn { "[findStatusWithLock] 예약 LOCK + 상태 조회 실패: reservationId=${id}" } + throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) + } + } + + @Transactional + fun markInProgress(reservationId: Long) { + log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." } + + findOrThrow(reservationId).apply { + this.status = ReservationStatus.PAYMENT_IN_PROGRESS + }.also { + log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 완료" } + } + } + private fun findOrThrow(id: Long): ReservationEntity { log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt index 7244a70f..29737d9c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/dto/ReservationFindDTO.kt @@ -35,3 +35,10 @@ data class ReserverInfo( data class ReservationOverviewListResponse( val reservations: List ) + +data class ReservationStateResponse( + val id: Long, + val scheduleId: Long, + val status: ReservationStatus, + val createdAt: Instant +) 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 7d1827f9..29a3500a 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,6 +1,8 @@ package com.sangdol.roomescape.reservation.infrastructure.persistence +import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -10,6 +12,10 @@ interface ReservationRepository : JpaRepository { fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List): List + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ReservationEntity r WHERE r._id = :id") + fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity? + @Modifying @Query( """ diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt index 052fffd4..33762eeb 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/mapper/ReservationMappingExtensions.kt @@ -4,6 +4,7 @@ import com.sangdol.roomescape.payment.dto.PaymentResponse import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse import com.sangdol.roomescape.reservation.dto.ReservationOverviewResponse +import com.sangdol.roomescape.reservation.dto.ReservationStateResponse import com.sangdol.roomescape.reservation.dto.ReserverInfo import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus @@ -57,3 +58,10 @@ private fun ReservationEntity.toReserverInfo() = ReserverInfo( participantCount = this.participantCount, requirement = this.requirement ) + +fun ReservationEntity.toStateResponse() = ReservationStateResponse( + id = this.id, + scheduleId = this.scheduleId, + status = this.status, + createdAt = this.createdAt +) -- 2.47.2 From e6040fcd44dceb7ec932f63afa36f500b1953630 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:20:18 +0900 Subject: [PATCH 29/45] =?UTF-8?q?feat:=20order=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20&=20=ED=9B=84=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/PaymentAttemptEntity.kt | 38 +++++++++++++++++++ .../persistence/PostOrderTaskEntity.kt | 16 ++++++++ .../persistence/PostOrderTaskRepository.kt | 6 +++ .../main/resources/schema/schema-mysql.sql | 23 +++++++++++ 4 files changed, 83 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt new file mode 100644 index 00000000..26de715c --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt @@ -0,0 +1,38 @@ +package com.sangdol.roomescape.order.infrastructure.persistence + +import com.sangdol.common.persistence.PersistableBaseEntity +import jakarta.persistence.* +import org.springframework.data.annotation.CreatedBy +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.Instant + +@Entity +@EntityListeners(AuditingEntityListener::class) +@Table(name = "payment_attempts") +class PaymentAttemptEntity( + id: Long, + + val reservationId: Long, + + @Enumerated(value = EnumType.STRING) + val result: AttemptResult, + + @Column(columnDefinition = "VARCHAR(50)") + val errorCode: String? = null, + + @Column(columnDefinition = "TEXT") + val message: String? = null, +) : PersistableBaseEntity(id) { + @Column(updatable = false) + @CreatedDate + lateinit var createdAt: Instant + + @Column(updatable = false) + @CreatedBy + var createdBy: Long = 0L +} + +enum class AttemptResult { + SUCCESS, FAILED +} \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt new file mode 100644 index 00000000..730ca5c8 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt @@ -0,0 +1,16 @@ +package com.sangdol.roomescape.order.infrastructure.persistence + +import com.sangdol.common.persistence.PersistableBaseEntity +import jakarta.persistence.Entity +import jakarta.persistence.Table +import java.time.Instant + +@Entity +@Table(name = "post_order_tasks") +class PostOrderTaskEntity( + id: Long, + val reservationId: Long, + val paymentKey: String, + val trial: Int, + val nextRetryAt: Instant +) : PersistableBaseEntity(id) \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt new file mode 100644 index 00000000..356215e8 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt @@ -0,0 +1,6 @@ +package com.sangdol.roomescape.order.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface PostOrderTaskRepository : JpaRepository { +} \ No newline at end of file diff --git a/service/src/main/resources/schema/schema-mysql.sql b/service/src/main/resources/schema/schema-mysql.sql index c3a4ab47..6c281116 100644 --- a/service/src/main/resources/schema/schema-mysql.sql +++ b/service/src/main/resources/schema/schema-mysql.sql @@ -238,3 +238,26 @@ create table if not exists canceled_payment( constraint uk_canceled_payment__paymentId unique (payment_id), constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment(id) ); + +create table if not exists payment_attempts( + id bigint primary key, + reservation_id bigint not null, + result varchar(20) not null, + error_code varchar(50) null, + message text null, + created_at datetime(6) not null, + created_by bigint not null, + + constraint fk_payment_attempts__reservation_id foreign key (reservation_id) references reservation (id), + index idx_payment_attempts__reservation_id_result (reservation_id, result) +); + +create table if not exists post_order_tasks( + id bigint primary key, + reservation_id bigint not null, + payment_key varchar(255) not null, + trial int not null, + next_retry_at datetime(6) not null, + + constraint uk_post_order_tasks__reservation_id unique (reservation_id) +); -- 2.47.2 From 25fc95fd2aeb97b61d907461444dcd714b65f056 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:23:32 +0900 Subject: [PATCH 30/45] =?UTF-8?q?feat:=20c4cd1681=20commit=EC=97=90=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=9C=20PaymentExceptionHandler=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/PaymentExceptionHandler.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentExceptionHandler.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentExceptionHandler.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentExceptionHandler.kt new file mode 100644 index 00000000..942bb6dc --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/exception/PaymentExceptionHandler.kt @@ -0,0 +1,40 @@ +package com.sangdol.roomescape.payment.exception + +import com.sangdol.common.types.web.CommonErrorResponse +import com.sangdol.common.web.support.log.WebLogMessageConverter +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +private val log: KLogger = KotlinLogging.logger {} + +@RestControllerAdvice +class PaymentExceptionHandler( + private val logMessageConverter: WebLogMessageConverter +) { + @ExceptionHandler(PaymentException::class) + fun handlePaymentException( + servletRequest: HttpServletRequest, + e: PaymentException + ): ResponseEntity { + val errorCode = e.errorCode + val httpStatus = errorCode.httpStatus + val errorResponse = CommonErrorResponse(errorCode, e.message) + + log.warn { + logMessageConverter.convertToErrorResponseMessage( + servletRequest = servletRequest, + httpStatus = httpStatus, + responseBody = errorResponse, + exception = if (e.message == errorCode.message) null else e + ) + } + + return ResponseEntity + .status(httpStatus.value()) + .body(errorResponse) + } +} -- 2.47.2 From 985efbe0a3e5d9b003f4037b0a1edbe9dfc42be6 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:25:20 +0900 Subject: [PATCH 31/45] =?UTF-8?q?feat:=20Order=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=98=88=EC=99=B8=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/exception/OrderErrorCode.kt | 20 +++++++++ .../order/exception/OrderException.kt | 16 +++++++ .../order/exception/OrderExceptionHandler.kt | 45 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt new file mode 100644 index 00000000..7bebf555 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt @@ -0,0 +1,20 @@ +package com.sangdol.roomescape.order.exception + +import com.sangdol.common.types.exception.ErrorCode +import com.sangdol.common.types.web.HttpStatus + +enum class OrderErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."), + BOOKING_PAYMENT_TIMEOUT(HttpStatus.CONFLICT, "B001", "결제 가능 시간을 초과했어요. 처음부터 다시 시도해주세요."), + BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B002", "이미 완료된 예약이에요."), + EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B003", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."), + CANCELED_RESERVATION(HttpStatus.CONFLICT, "B004", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."), + PAST_SCHEDULE(HttpStatus.CONFLICT, "B005", "지난 일정은 예약할 수 없어요."), + + BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.") + ; +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt new file mode 100644 index 00000000..2306f9d4 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt @@ -0,0 +1,16 @@ +package com.sangdol.roomescape.order.exception + +import com.sangdol.common.types.exception.ErrorCode +import com.sangdol.common.types.exception.RoomescapeException + +class OrderException( + override val errorCode: ErrorCode, + override val message: String = errorCode.message, + var trial: Long = 0 +) : RoomescapeException(errorCode, message) + +class OrderErrorResponse( + val code: String, + val message: String, + val trial: Long +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt new file mode 100644 index 00000000..0b72d953 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt @@ -0,0 +1,45 @@ +package com.sangdol.roomescape.order.exception + +import com.sangdol.common.types.exception.ErrorCode +import com.sangdol.common.types.web.HttpStatus +import com.sangdol.common.web.support.log.WebLogMessageConverter +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +private val log: KLogger = KotlinLogging.logger {} + +@RestControllerAdvice +class OrderExceptionHandler( + private val messageConverter: WebLogMessageConverter +) { + @ExceptionHandler(OrderException::class) + fun handleOrderException( + servletRequest: HttpServletRequest, + e: OrderException + ): ResponseEntity { + val errorCode: ErrorCode = e.errorCode + val httpStatus: HttpStatus = errorCode.httpStatus + val errorResponse = OrderErrorResponse( + code = errorCode.errorCode, + message = e.message, + trial = e.trial + ) + + log.info { + messageConverter.convertToErrorResponseMessage( + servletRequest = servletRequest, + httpStatus = httpStatus, + responseBody = errorResponse, + exception = if (errorCode.message == e.message) null else e + ) + } + + return ResponseEntity + .status(httpStatus.value()) + .body(errorResponse) + } +} -- 2.47.2 From 6be9ae7efe2861b471c162a13fe524da184b125f Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:28:02 +0900 Subject: [PATCH 32/45] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20&=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=ED=99=95=EC=A0=95=20=EA=B3=BC=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=95=84=EC=9A=94=ED=95=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/business/OrderValidator.kt | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt new file mode 100644 index 00000000..e475f260 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt @@ -0,0 +1,76 @@ +package com.sangdol.roomescape.order.business + +import com.sangdol.common.utils.KoreaDateTime +import com.sangdol.roomescape.order.exception.OrderErrorCode +import com.sangdol.roomescape.order.exception.OrderException +import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository +import com.sangdol.roomescape.reservation.dto.ReservationStateResponse +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.time.Instant +import java.time.LocalDateTime + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class OrderValidator( + private val paymentAttemptRepository: PaymentAttemptRepository +) { + fun validateCanConfirm( + reservation: ReservationStateResponse, + schedule: ScheduleStateResponse + ) { + if (paymentAttemptRepository.isSuccessAttemptExists(reservation.id)) { + log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservation.id}" } + throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) + } + + validateReservationStatus(reservation) + validateScheduleStatus(schedule) + } + + private fun validateReservationStatus(reservation: ReservationStateResponse) { + when (reservation.status) { + ReservationStatus.CONFIRMED -> { + log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" } + throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) + } + ReservationStatus.EXPIRED -> { + log.info { "[validateCanConfirm] 만료된 예약 예약: id=${reservation.id}" } + throw OrderException(OrderErrorCode.EXPIRED_RESERVATION) + } + ReservationStatus.CANCELED -> { + log.info { "[validateCanConfirm] 취소된 예약 예약: id=${reservation.id}" } + 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 -> {} + } + } + + private fun validateScheduleStatus(schedule: ScheduleStateResponse) { + if (schedule.status != ScheduleStatus.HOLD) { + log.info { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" } + throw OrderException(OrderErrorCode.EXPIRED_RESERVATION) + } + + val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom) + val nowDateTime = KoreaDateTime.now() + if (scheduleDateTime.isBefore(nowDateTime)) { + log.info { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" } + throw OrderException(OrderErrorCode.PAST_SCHEDULE) + } + } +} -- 2.47.2 From edf4d3af24fc9e831ac262c1bdf407f09f7578ac Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:33:58 +0900 Subject: [PATCH 33/45] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9D=B4=ED=9B=84=20=EC=98=88=EC=95=BD=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=B0=EC=A0=9C=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=B3=84=EB=8F=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/OrderPostProcessorService.kt | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt new file mode 100644 index 00000000..ccd7c9b9 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt @@ -0,0 +1,60 @@ +package com.sangdol.roomescape.order.business + +import com.sangdol.common.persistence.IDGenerator +import com.sangdol.common.persistence.TransactionExecutionUtil +import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskEntity +import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository +import com.sangdol.roomescape.payment.business.PaymentService +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.reservation.business.ReservationService +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class OrderPostProcessorService( + private val idGenerator: IDGenerator, + private val reservationService: ReservationService, + private val paymentService: PaymentService, + private val postOrderTaskRepository: PostOrderTaskRepository, + private val transactionExecutionUtil: TransactionExecutionUtil +) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun processAfterPaymentConfirmation( + reservationId: Long, + paymentResponse: PaymentGatewayResponse + ) { + val paymentKey = paymentResponse.paymentKey + try { + log.info { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } + + val paymentCreateResponse = paymentService.savePayment(reservationId, paymentResponse) + reservationService.confirmReservation(reservationId) + + log.info { + "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 완료: reservationId=${reservationId}, paymentKey=${paymentKey}, paymentId=${paymentCreateResponse.paymentId}, paymentDetailId=${paymentCreateResponse.detailId}" + } + } catch (_: Exception) { + log.warn { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 실패. 작업 저장 시작: reservationId=${reservationId}, paymentKey=$paymentKey}" } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + PostOrderTaskEntity( + id = idGenerator.create(), + reservationId = reservationId, + paymentKey = paymentKey, + trial = 1, + nextRetryAt = Instant.now().plusSeconds(30), + ).also { + postOrderTaskRepository.save(it) + } + } + + log.info { "[processAfterPaymentConfirmation] 작업 저장 완료" } + } + } +} -- 2.47.2 From 17fb44573dd3e7504e052193993f00e01604d9d2 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 7 Oct 2025 22:34:19 +0900 Subject: [PATCH 34/45] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20&=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=ED=86=B5=ED=95=A9=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/order/business/OrderService.kt | 130 ++++++++++++++++++ .../sangdol/roomescape/order/docs/OrderAPI.kt | 22 +++ .../roomescape/order/web/OrderController.kt | 25 ++++ .../payment/business/PaymentService.kt | 5 +- 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt new file mode 100644 index 00000000..1662d322 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt @@ -0,0 +1,130 @@ +package com.sangdol.roomescape.order.business + +import com.sangdol.common.persistence.IDGenerator +import com.sangdol.common.persistence.TransactionExecutionUtil +import com.sangdol.common.types.exception.ErrorCode +import com.sangdol.common.types.exception.RoomescapeException +import com.sangdol.roomescape.order.exception.OrderErrorCode +import com.sangdol.roomescape.order.exception.OrderException +import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult +import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity +import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository +import com.sangdol.roomescape.payment.business.PaymentService +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.exception.PaymentException +import com.sangdol.roomescape.reservation.business.ReservationService +import com.sangdol.roomescape.reservation.dto.ReservationStateResponse +import com.sangdol.roomescape.schedule.business.ScheduleService +import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class OrderService( + private val idGenerator: IDGenerator, + private val reservationService: ReservationService, + private val scheduleService: ScheduleService, + private val paymentService: PaymentService, + private val transactionExecutionUtil: TransactionExecutionUtil, + private val orderValidator: OrderValidator, + private val paymentAttemptRepository: PaymentAttemptRepository, + private val orderPostProcessorService: OrderPostProcessorService +) { + + fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) { + val trial = paymentAttemptRepository.countByReservationId(reservationId) + val paymentKey = paymentConfirmRequest.paymentKey + + log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } + + try { + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + validateAndMarkInProgress(reservationId) + } + + val paymentClientResponse: PaymentGatewayResponse = + requestConfirmPayment(reservationId, paymentConfirmRequest) + + orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse) + } catch (e: Exception) { + val errorCode: ErrorCode = if (e is RoomescapeException) { + e.errorCode + } else { + OrderErrorCode.BOOKING_UNEXPECTED_ERROR + } + + throw OrderException(errorCode, e.message ?: errorCode.message, trial) + } + } + + private fun validateAndMarkInProgress(reservationId: Long) { + log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" } + val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId) + val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId) + + try { + orderValidator.validateCanConfirm(reservation, schedule) + log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" } + } catch (e: OrderException) { + val errorCode = OrderErrorCode.NOT_CONFIRMABLE + throw OrderException(errorCode, e.message) + } + + reservationService.markInProgress(reservationId) + } + + private fun requestConfirmPayment( + reservationId: Long, + paymentConfirmRequest: PaymentConfirmRequest + ): PaymentGatewayResponse { + log.info { "[requestConfirmPayment] 결제 및 이력 저장 시작: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" } + val paymentResponse: PaymentGatewayResponse + var attempt: PaymentAttemptEntity? = null + + try { + paymentResponse = paymentService.requestConfirm(paymentConfirmRequest) + + attempt = PaymentAttemptEntity( + id = idGenerator.create(), + reservationId = reservationId, + result = AttemptResult.SUCCESS, + ) + } catch (e: Exception) { + val errorCode: String = if (e is PaymentException) { + log.info { "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" } + e.errorCode.name + } else { + log.warn { + "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" + } + OrderErrorCode.BOOKING_UNEXPECTED_ERROR.name + } + + attempt = PaymentAttemptEntity( + id = idGenerator.create(), + reservationId = reservationId, + result = AttemptResult.FAILED, + errorCode = errorCode, + message = e.message + ) + + throw e + } finally { + val savedAttempt: PaymentAttemptEntity? = attempt?.let { + log.info { "[requestPayment] 결제 요청 이력 저장 시작: id=${it.id}, reservationId=${it.reservationId}, result=${it.result}, errorCode=${it.errorCode}, message=${it.message}" } + paymentAttemptRepository.save(it) + } + savedAttempt?.also { + log.info { "[requestPayment] 결제 요청 이력 저장 완료: id=${savedAttempt.id}" } + } ?: run { + log.info { "[requestPayment] 결제 요청 이력 저장 실패: reservationId=${reservationId}" } + } + } + + return paymentResponse + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt new file mode 100644 index 00000000..abb5932b --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/docs/OrderAPI.kt @@ -0,0 +1,22 @@ +package com.sangdol.roomescape.order.docs + +import com.sangdol.common.types.web.CommonApiResponse +import com.sangdol.roomescape.auth.web.support.UserOnly +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody + +interface OrderAPI { + + @UserOnly + @Operation(summary = "결제 및 예약 완료 처리") + @ApiResponses(ApiResponse(responseCode = "200")) + fun confirm( + @PathVariable("reservationId") reservationId: Long, + @RequestBody request: PaymentConfirmRequest + ): ResponseEntity> +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt new file mode 100644 index 00000000..cfd0f572 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/web/OrderController.kt @@ -0,0 +1,25 @@ +package com.sangdol.roomescape.order.web + +import com.sangdol.common.types.web.CommonApiResponse +import com.sangdol.roomescape.order.business.OrderService +import com.sangdol.roomescape.order.docs.OrderAPI +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/orders") +class OrderController( + private val orderService: OrderService +) : OrderAPI { + + @PostMapping("/{reservationId}/confirm") + override fun confirm( + @PathVariable("reservationId") reservationId: Long, + @RequestBody request: PaymentConfirmRequest + ): ResponseEntity> { + orderService.confirm(reservationId, request) + + return ResponseEntity.ok(CommonApiResponse()) + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index f2f0010d..7985b20d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -31,8 +31,11 @@ class PaymentService( private val transactionExecutionUtil: TransactionExecutionUtil, ) { fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse { + log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" } try { - return paymentClient.confirm(request.paymentKey, request.orderId, request.amount) + return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also { + log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" } + } } catch (e: ExternalPaymentException) { val errorCode = if (e.httpStatusCode in 400..<500) { PaymentErrorCode.PAYMENT_CLIENT_ERROR -- 2.47.2 From d8947502795850d35d340f4480f8c2a38ff08f13 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 13:54:46 +0900 Subject: [PATCH 35/45] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=9E=AC=ED=99=9C=EC=84=B1=ED=99=94=20=EA=B3=BC=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20DeadLock=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20SKIP=20LOCKED=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=20=EB=A7=8C=EB=A3=8C=20=EA=B2=80=EC=A6=9D=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationValidator.kt | 7 - .../IncompletedReservationScheduler.kt | 10 +- .../persistence/ScheduleRepository.kt | 30 +++-- .../reservation/ReservationApiTest.kt | 15 --- .../reservation/ReservationConcurrencyTest.kt | 123 ++++++++++-------- 5 files changed, 96 insertions(+), 89 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 8eb6671b..f0993c21 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -33,13 +33,6 @@ class ReservationValidator { 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 nowDateTime = KoreaDateTime.now() if (scheduleDateTime.isBefore(nowDateTime)) { 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 index 01edf4ad..68bb5393 100644 --- 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 @@ -24,10 +24,14 @@ class IncompletedReservationScheduler( @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Transactional fun processExpiredHoldSchedule() { - log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } + log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } - scheduleRepository.releaseExpiredHolds(Instant.now()).also { - log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } + val targets: List = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also { + log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" } + } + + scheduleRepository.releaseHeldSchedules(targets).also { + log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" } } } 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 08f9ffb3..a04f52cf 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 @@ -126,6 +126,26 @@ interface ScheduleRepository : JpaRepository { expiredAt: Instant = Instant.now().plusSeconds(5 * 60) ): 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 + @Modifying @Query( """ @@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository { 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 AND r.status = com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus.PENDING - ) + s._id IN :scheduleIds """ ) - fun releaseExpiredHolds(@Param("now") now: Instant): Int + fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt index d29cc9d4..8436fce6 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -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("현재 시간이 일정의 시작 시간 이후이면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( status = ScheduleStatus.HOLD, diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt index 1940f3f4..8195a58f 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -3,12 +3,11 @@ package com.sangdol.roomescape.reservation import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.reservation.business.ReservationService 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.ReservationException import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository 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.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus @@ -19,7 +18,9 @@ import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe 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.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate @@ -33,76 +34,86 @@ class ReservationConcurrencyTest( ) : FunSpecSpringbootTest() { init { - context("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 로직의 완료 이후에 처리된다.") { - lateinit var user: UserEntity + lateinit var user: UserEntity + lateinit var schedule: ScheduleEntity - beforeTest { - user = testAuthUtil.defaultUserLogin().first - } + beforeTest { + user = testAuthUtil.defaultUserLogin().first + schedule = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = true + ) + } - test("holdExpiredAt이 지난 일정인 경우, Pending 예약 생성 중 예외가 발생하고 이후 배치가 재활성화 처리한다.") { - val schedule = dummyInitializer.createSchedule( - status = ScheduleStatus.HOLD, - isHoldExpired = true - ) - try { - runConcurrency(user, schedule) - } catch (e: ReservationException) { - e.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE + test("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 일정이 만료되었더라도 처리하지 않는다.") { + val createdReservationId = withContext(Dispatchers.IO) { + val createPendingReservationJob = async { + TransactionTemplate(transactionManager).execute { + createPendingReservation(user, schedule) + }!! + } - assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.AVAILABLE - this.holdExpiredAt shouldBe null + val batchJob = async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredHoldSchedule() } } + + createPendingReservationJob.await().also { batchJob.await() } } - test("holdExpiredAt이 지나지 않은 일정인 경우, Pending 예약 생성이 정상적으로 종료되며 배치 작업이 적용되지 않는다.") { - val schedule = dummyInitializer.createSchedule( - status = ScheduleStatus.HOLD, - isHoldExpired = false - ) + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.HOLD + this.holdExpiredAt.shouldNotBeNull() + } - val response = runConcurrency(user, schedule) + assertSoftly(reservationRepository.findByIdOrNull(createdReservationId)) { + this.shouldNotBeNull() + this.status shouldBe ReservationStatus.PENDING + } + } - assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.HOLD - this.holdExpiredAt.shouldNotBeNull() + test("Pending 예약 생성 직전에 배치가 시작되면 예약 생성에 실패한다.") { + withContext(Dispatchers.IO) { + val batchJob = async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredHoldSchedule() + } } - assertSoftly(reservationRepository.findByIdOrNull(response.id)) { - this.shouldNotBeNull() - this.status shouldBe ReservationStatus.PENDING + delay(5) + + val createPendingReservationJob = async { + assertThrows { + TransactionTemplate(transactionManager).execute { + createPendingReservation(user, schedule) + }!! + }.also { + it.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE + } } + + createPendingReservationJob.await().also { batchJob.await() } + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null } } } - 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() } - } + private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long { + return reservationService.createPendingReservation( + user = CurrentUserContext(id = user.id, name = user.name), + request = PendingReservationCreateRequest( + scheduleId = schedule.id, + reserverName = user.name, + reserverContact = user.phone, + participantCount = 3, + requirement = "없어요!" + ) + ).id } } -- 2.47.2 From 7fe33d24d292d57542f163b81bb3dd70afcb8947 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 13:55:37 +0900 Subject: [PATCH 36/45] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20DeadLock=20=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20SKIP=20LOCKED=20=EA=B3=BC=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=98=88=EC=95=BD=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=A1=B0=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/business/OrderValidator.kt | 14 +- .../order/exception/OrderErrorCode.kt | 9 +- .../IncompletedReservationScheduler.kt | 10 +- .../persistence/ReservationRepository.kt | 18 ++- .../roomescape/order/OrderConcurrencyTest.kt | 130 ++++++++++++++++++ .../sangdol/roomescape/supports/Fixtures.kt | 6 +- 6 files changed, 162 insertions(+), 25 deletions(-) create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt index e475f260..8ee18671 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt @@ -11,7 +11,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component -import java.time.Instant import java.time.LocalDateTime private val log: KLogger = KotlinLogging.logger {} @@ -40,22 +39,13 @@ class OrderValidator( throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) } ReservationStatus.EXPIRED -> { - log.info { "[validateCanConfirm] 만료된 예약 예약: id=${reservation.id}" } + log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" } throw OrderException(OrderErrorCode.EXPIRED_RESERVATION) } ReservationStatus.CANCELED -> { - log.info { "[validateCanConfirm] 취소된 예약 예약: id=${reservation.id}" } + log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" } 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 -> {} } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt index 7bebf555..4ed09f47 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt @@ -9,11 +9,10 @@ enum class OrderErrorCode( override val message: String ) : ErrorCode { NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."), - BOOKING_PAYMENT_TIMEOUT(HttpStatus.CONFLICT, "B001", "결제 가능 시간을 초과했어요. 처음부터 다시 시도해주세요."), - BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B002", "이미 완료된 예약이에요."), - EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B003", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."), - CANCELED_RESERVATION(HttpStatus.CONFLICT, "B004", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."), - PAST_SCHEDULE(HttpStatus.CONFLICT, "B005", "지난 일정은 예약할 수 없어요."), + BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."), + EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."), + CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."), + PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."), BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.") ; 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 index 68bb5393..6b596e41 100644 --- 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 @@ -38,10 +38,14 @@ class IncompletedReservationScheduler( @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Transactional fun processExpiredReservation() { - log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } + log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" } - reservationRepository.expirePendingReservations(Instant.now()).also { - log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" } + val targets: List = reservationRepository.findAllExpiredReservation().also { + log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" } + } + + reservationRepository.expirePendingReservations(Instant.now(), targets).also { + log.info { "[processExpiredReservation] ${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 29a3500a..fc56aa64 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 @@ -16,6 +16,20 @@ interface ReservationRepository : JpaRepository { @Query("SELECT r FROM ReservationEntity r WHERE r._id = :id") 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 + @Modifying @Query( """ @@ -29,8 +43,8 @@ interface ReservationRepository : JpaRepository { s.status = 'AVAILABLE', s.hold_expired_at = NULL WHERE - r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) + r.id IN :reservationIds """, nativeQuery = true ) - fun expirePendingReservations(@Param("now") now: Instant): Int + fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt new file mode 100644 index 00000000..54519080 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt @@ -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 { + 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 + } + } + } +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 821517f8..d90148f6 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -295,9 +295,9 @@ object PaymentFixture { paymentKey: String, amount: Int, method: PaymentMethod, - cardDetail: CardDetailResponse?, - easyPayDetail: EasyPayDetailResponse?, - transferDetail: TransferDetailResponse?, + cardDetail: CardDetailResponse? = null, + easyPayDetail: EasyPayDetailResponse? = null, + transferDetail: TransferDetailResponse? = null, orderId: String = randomString(25), ) = PaymentGatewayResponse( paymentKey = paymentKey, -- 2.47.2 From 7c02d9ceae1aefa651cd79b4063dbca23826e712 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:10:54 +0900 Subject: [PATCH 37/45] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=8D=94=EB=AF=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 만료, 취소 예약 기능 추가 - Payment 생성 시 입력된 method에 따른 분기 처리 --- .../roomescape/supports/DummyInitializer.kt | 21 +++++++++ .../sangdol/roomescape/supports/Fixtures.kt | 47 +++++++++++++------ 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index e40bb5a8..dca0fefe 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -156,6 +156,27 @@ class DummyInitializer( } } + fun createExpiredOrCanceledReservation( + user: UserEntity, + status: ReservationStatus, + storeId: Long = IDGenerator.create(), + themeRequest: ThemeCreateRequest = ThemeFixture.createRequest, + scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, + reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, + ): ReservationEntity { + return createPendingReservation(user, storeId, themeRequest, scheduleRequest, reservationRequest).apply { + this.status = status + }.also { + reservationRepository.save(it) + + scheduleRepository.findByIdOrNull(it.scheduleId)?.let { schedule -> + schedule.status = ScheduleStatus.AVAILABLE + schedule.holdExpiredAt = null + scheduleRepository.save(schedule) + } + } + } + fun createPayment( reservationId: Long, request: PaymentConfirmRequest = PaymentFixture.confirmRequest, diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index d90148f6..416a539b 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -299,21 +299,38 @@ object PaymentFixture { easyPayDetail: EasyPayDetailResponse? = null, transferDetail: TransferDetailResponse? = null, orderId: String = randomString(25), - ) = PaymentGatewayResponse( - paymentKey = paymentKey, - status = PaymentStatus.DONE, - orderId = orderId, - type = PaymentType.NORMAL, - totalAmount = amount, - vat = (amount * 0.1).toInt(), - suppliedAmount = (amount * 0.9).toInt(), - method = method, - card = cardDetail, - easyPay = easyPayDetail, - transfer = transferDetail, - requestedAt = KoreaDateTime.nowWithOffset(), - approvedAt = KoreaDateTime.nowWithOffset().plusSeconds(5) - ) + ): PaymentGatewayResponse { + var card: CardDetailResponse? = cardDetail + if (method == PaymentMethod.CARD && cardDetail == null) { + card = cardDetail(amount) + } + + var easypay: EasyPayDetailResponse? = easyPayDetail + if (method == PaymentMethod.EASY_PAY && easyPayDetail == null) { + easypay = easypayDetail(amount) + } + + var transfer: TransferDetailResponse? = transferDetail + if (method == PaymentMethod.TRANSFER && transferDetail == null) { + transfer = transferDetail() + } + + return PaymentGatewayResponse( + paymentKey = paymentKey, + status = PaymentStatus.DONE, + orderId = orderId, + type = PaymentType.NORMAL, + totalAmount = amount, + vat = (amount * 0.1).toInt(), + suppliedAmount = (amount * 0.9).toInt(), + method = method, + card = card, + easyPay = easypay, + transfer = transfer, + requestedAt = KoreaDateTime.nowWithOffset(), + approvedAt = KoreaDateTime.nowWithOffset().plusSeconds(5) + ) + } fun cancelResponse( amount: Int, -- 2.47.2 From 7482b3de995136c605e026385c58ef40e89baa00 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:11:22 +0900 Subject: [PATCH 38/45] =?UTF-8?q?refactor:=20ReservationConcurrencyTest?= =?UTF-8?q?=EC=97=90=20LOCK=20=EC=9D=B4=ED=9B=84=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20delay=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/reservation/ReservationConcurrencyTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt index 8195a58f..e6866c11 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -54,6 +54,8 @@ class ReservationConcurrencyTest( }!! } + delay(10) + val batchJob = async { TransactionTemplate(transactionManager).execute { incompletedReservationScheduler.processExpiredHoldSchedule() -- 2.47.2 From ff516ef48f7d7f55ee8d026d95d3bc302dccf6cb Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:11:49 +0900 Subject: [PATCH 39/45] =?UTF-8?q?refactor:=20OrderPostProcessorService=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EC=98=88=EC=99=B8=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=EC=8B=9C=20=EC=98=88=EC=99=B8=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/order/business/OrderPostProcessorService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt index ccd7c9b9..f2fde854 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt @@ -39,8 +39,8 @@ class OrderPostProcessorService( log.info { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 완료: reservationId=${reservationId}, paymentKey=${paymentKey}, paymentId=${paymentCreateResponse.paymentId}, paymentDetailId=${paymentCreateResponse.detailId}" } - } catch (_: Exception) { - log.warn { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 실패. 작업 저장 시작: reservationId=${reservationId}, paymentKey=$paymentKey}" } + } catch (e: Exception) { + log.warn(e) { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 실패. 작업 저장 시작: reservationId=${reservationId}, paymentKey=$paymentKey}" } transactionExecutionUtil.withNewTransaction(isReadOnly = false) { PostOrderTaskEntity( -- 2.47.2 From c76f6bba689f31871190173be5fd339812b0d5fe Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:12:27 +0900 Subject: [PATCH 40/45] =?UTF-8?q?refactor:=20OrderExceptionHandler?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=98=88=EC=99=B8=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sangdol/roomescape/order/exception/OrderExceptionHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt index 0b72d953..908450a3 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt @@ -25,7 +25,7 @@ class OrderExceptionHandler( val httpStatus: HttpStatus = errorCode.httpStatus val errorResponse = OrderErrorResponse( code = errorCode.errorCode, - message = e.message, + message = if (httpStatus.isClientError()) e.message else errorCode.message, trial = e.trial ) -- 2.47.2 From 60882bee85580dda2261d35387c1b9979ef58df8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:13:01 +0900 Subject: [PATCH 41/45] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=98=88=EC=95=BD=EC=9D=98=20PAYMENT=5FIN?= =?UTF-8?q?=5FPROGRESS=20=EC=83=81=ED=83=9C=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/infrastructure/persistence/ScheduleRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a04f52cf..62d6be00 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 @@ -139,7 +139,7 @@ interface ScheduleRepository : JpaRepository { AND NOT EXISTS ( SELECT 1 FROM reservation r - WHERE r.schedule_id = s.id AND r.status = 'PENDING' + WHERE r.schedule_id = s.id AND (r.status = 'PENDING' OR r.status = 'PAYMENT_IN_PROGRESS') ) FOR UPDATE SKIP LOCKED """, nativeQuery = true -- 2.47.2 From 876725e0e17b79ba7ac9237613bf5727e020df9d Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:13:19 +0900 Subject: [PATCH 42/45] =?UTF-8?q?test:=20=EA=B2=B0=EC=A0=9C=20&=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=ED=99=95=EC=A0=95=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 --- .../sangdol/roomescape/order/OrderApiTest.kt | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt diff --git a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt new file mode 100644 index 00000000..5b0fd1be --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt @@ -0,0 +1,293 @@ +package com.sangdol.roomescape.order + +import com.ninjasquad.springmockk.SpykBean +import com.sangdol.common.utils.KoreaDate +import com.sangdol.common.utils.KoreaTime +import com.sangdol.roomescape.auth.exception.AuthErrorCode +import com.sangdol.roomescape.order.exception.OrderErrorCode +import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult +import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity +import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository +import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository +import com.sangdol.roomescape.payment.business.PaymentService +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.exception.PaymentErrorCode +import com.sangdol.roomescape.payment.exception.PaymentException +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository +import com.sangdol.roomescape.reservation.exception.ReservationErrorCode +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.supports.* +import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus + +class OrderApiTest( + @SpykBean private val paymentService: PaymentService, + private val paymentAttemptRepository: PaymentAttemptRepository, + private val reservationRepository: ReservationRepository, + private val postOrderTaskRepository: PostOrderTaskRepository, + private val scheduleRepository: ScheduleRepository, + private val paymentRepository: PaymentRepository, + private val paymentDetailRepository: PaymentDetailRepository +) : FunSpecSpringbootTest() { + + val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest + val expectedPaymentResponse: PaymentGatewayResponse = PaymentFixture.confirmResponse( + paymentKey = paymentRequest.paymentKey, + orderId = paymentRequest.orderId, + amount = paymentRequest.amount, + method = PaymentMethod.CARD + ) + + init { + context("결제 및 예약을 확정한다.") { + lateinit var user: UserEntity + lateinit var token: String + + beforeTest { + val loginResult = testAuthUtil.defaultUserLogin() + user = loginResult.first + token = loginResult.second + } + + context("권한이 없으면 접근할 수 없다.") { + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = "/orders/${INVALID_PK}/confirm", + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = testAuthUtil.defaultHqAdminLogin().second, + method = HttpMethod.POST, + endpoint = "/orders/${INVALID_PK}/confirm", + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + + test("정상 응답") { + val reservation = dummyInitializer.createPendingReservation(user) + + every { + paymentService.requestConfirm(paymentRequest) + } returns expectedPaymentResponse + + runTest( + token = token, + using = { + body(paymentRequest) + }, + on = { + post("/orders/${reservation.id}/confirm") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + + assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) { + this.status shouldBe ScheduleStatus.RESERVED + this.holdExpiredAt shouldBe null + } + reservationRepository.findByIdOrNull(reservation.id)!!.status shouldBe ReservationStatus.CONFIRMED + + assertSoftly(paymentRepository.findByReservationId(reservation.id)) { + this.shouldNotBeNull() + this.status shouldBe expectedPaymentResponse.status + + paymentDetailRepository.findByPaymentId(this.id)!!.shouldNotBeNull() + } + + paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue() + } + + context("검증 과정에서의 실패 응답") { + test("예약이 없으면 실패한다.") { + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${INVALID_PK}/confirm", + requestBody = paymentRequest, + expectedErrorCode = ReservationErrorCode.RESERVATION_NOT_FOUND + ) + } + + test("이미 결제가 완료된 예약이면 실패한다.") { + val reservation = dummyInitializer.createPendingReservation(user) + + paymentAttemptRepository.save(PaymentAttemptEntity( + id = IDGenerator.create(), + reservationId = reservation.id, + result = AttemptResult.SUCCESS + )) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${reservation.id}/confirm", + requestBody = paymentRequest, + expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE + ) + } + + test("이미 확정된 예약이면 실패한다.") { + val reservation = dummyInitializer.createConfirmReservation(user) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${reservation.id}/confirm", + requestBody = paymentRequest, + expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE + ) + } + + test("만료된 예약이면 실패한다.") { + val reservation = dummyInitializer.createExpiredOrCanceledReservation(user, ReservationStatus.EXPIRED) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${reservation.id}/confirm", + requestBody = paymentRequest, + expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE + ) + } + + test("취소된 예약이면 실패한다.") { + val reservation = dummyInitializer.createExpiredOrCanceledReservation(user, ReservationStatus.CANCELED) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${reservation.id}/confirm", + requestBody = paymentRequest, + expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE + ) + } + + test("일정이 HOLD 상태가 아니라면 실패한다.") { + val schedule = dummyInitializer.createSchedule(status = ScheduleStatus.AVAILABLE) + val reservation = dummyInitializer.createPendingReservation( + user = user, + reservationRequest = ReservationFixture.pendingCreateRequest.copy(scheduleId = schedule.id) + ) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${reservation.id}/confirm", + requestBody = paymentRequest, + expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE + ) + } + + test("일정의 시작 시간이 현재 시간 이전이면 실패한다.") { + val schedule = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + request = ScheduleFixture.createRequest.copy( + date = KoreaDate.today(), + time = KoreaTime.now() + ) + ) + + val reservation = dummyInitializer.createPendingReservation( + user = user, + reservationRequest = ReservationFixture.pendingCreateRequest.copy(scheduleId = schedule.id) + ) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${reservation.id}/confirm", + requestBody = paymentRequest, + expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE + ) + } + } + + context("결제 과정에서의 실패 응답.") { + test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") { + val reservation = dummyInitializer.createPendingReservation(user) + + every { + paymentService.requestConfirm(paymentRequest) + } throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR) + + runExceptionTest( + token = token, + method = HttpMethod.POST, + endpoint = "/orders/${reservation.id}/confirm", + requestBody = paymentRequest, + expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR + ).also { + it.extract().path("trial") shouldBe 0 + } + + assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { + this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS + } + + val paymentAttempt = paymentAttemptRepository.findAll().first { it.reservationId == reservation.id } + assertSoftly(paymentAttempt) { + it.shouldNotBeNull() + it.result shouldBe AttemptResult.FAILED + it.errorCode shouldBe PaymentErrorCode.PAYMENT_CLIENT_ERROR.name + } + } + } + + context("결제 성공 이후 실패 응답.") { + test("결제 이력 저장 과정에서 예외가 발생하면 해당 작업을 저장하며, 사용자는 정상 응답을 받는다.") { + val reservation = dummyInitializer.createPendingReservation(user) + + every { + paymentService.requestConfirm(paymentRequest) + } returns expectedPaymentResponse + + every { + paymentService.savePayment(reservation.id, expectedPaymentResponse) + } throws RuntimeException("결제 저장 실패!") + + runTest( + token = token, + using = { + body(paymentRequest) + }, + on = { + post("/orders/${reservation.id}/confirm") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + + paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue() + + val postOrderTask = postOrderTaskRepository.findAll().first { it.reservationId == reservation.id } + assertSoftly(postOrderTask) { + it.shouldNotBeNull() + it.paymentKey shouldBe paymentRequest.paymentKey + it.trial shouldBe 1 + } + } + } + } + } +} -- 2.47.2 From 308059b5b88361f532f130073e9f5c245e55f241 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:55:45 +0900 Subject: [PATCH 43/45] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B6=80=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sangdol/roomescape/store/business/StoreService.kt | 4 ++-- .../kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt | 4 ++-- .../kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt | 2 +- .../roomescape/store/mapper/StoreMappingExtensions.kt | 4 ++-- .../sangdol/roomescape/store/web/AdminStoreController.kt | 6 +++--- service/src/main/resources/application.yaml | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt index ab2e4b1d..e82ce88e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/business/StoreService.kt @@ -4,7 +4,7 @@ import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.region.business.RegionService -import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreDetailResponse import com.sangdol.roomescape.store.dto.StoreNameListResponse import com.sangdol.roomescape.store.dto.StoreInfoResponse import com.sangdol.roomescape.store.dto.StoreRegisterRequest @@ -34,7 +34,7 @@ class StoreService( private val idGenerator: IDGenerator, ) { @Transactional(readOnly = true) - fun getDetail(id: Long): DetailStoreResponse { + fun getDetail(id: Long): StoreDetailResponse { log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" } val store: StoreEntity = findOrThrow(id) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt index 585e6ce6..d43ef962 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/docs/StoreAPI.kt @@ -5,7 +5,7 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.Public -import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreDetailResponse import com.sangdol.roomescape.store.dto.StoreNameListResponse import com.sangdol.roomescape.store.dto.StoreInfoResponse import com.sangdol.roomescape.store.dto.StoreRegisterRequest @@ -26,7 +26,7 @@ interface AdminStoreAPI { @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) fun findStoreDetail( @PathVariable id: Long - ): ResponseEntity> + ): ResponseEntity> @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) @Operation(summary = "매장 등록") diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt index 98252fe4..3ce10d93 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/dto/StoreFindDTO.kt @@ -20,7 +20,7 @@ data class StoreInfoResponse( val businessRegNum: String ) -data class DetailStoreResponse( +data class StoreDetailResponse( val id: Long, val name: String, val address: String, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt index 0e387688..cdb7fc3e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/mapper/StoreMappingExtensions.kt @@ -2,7 +2,7 @@ package com.sangdol.roomescape.store.mapper import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.region.dto.RegionInfoResponse -import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreDetailResponse import com.sangdol.roomescape.store.dto.StoreInfoResponse import com.sangdol.roomescape.store.dto.StoreNameListResponse import com.sangdol.roomescape.store.dto.StoreNameResponse @@ -19,7 +19,7 @@ fun StoreEntity.toInfoResponse() = StoreInfoResponse( fun StoreEntity.toDetailResponse( region: RegionInfoResponse, audit: AuditingInfo -) = DetailStoreResponse( +) = StoreDetailResponse( id = this.id, name = this.name, address = this.address, diff --git a/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt b/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt index 2074ae9f..cb074f36 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/store/web/AdminStoreController.kt @@ -3,7 +3,7 @@ package com.sangdol.roomescape.store.web import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.store.business.StoreService import com.sangdol.roomescape.store.docs.AdminStoreAPI -import com.sangdol.roomescape.store.dto.DetailStoreResponse +import com.sangdol.roomescape.store.dto.StoreDetailResponse import com.sangdol.roomescape.store.dto.StoreRegisterRequest import com.sangdol.roomescape.store.dto.StoreRegisterResponse import com.sangdol.roomescape.store.dto.StoreUpdateRequest @@ -20,8 +20,8 @@ class AdminStoreController( @GetMapping("/{id}/detail") override fun findStoreDetail( @PathVariable id: Long - ): ResponseEntity> { - val response: DetailStoreResponse = storeService.getDetail(id) + ): ResponseEntity> { + val response: StoreDetailResponse = storeService.getDetail(id) return ResponseEntity.ok(CommonApiResponse(response)) } diff --git a/service/src/main/resources/application.yaml b/service/src/main/resources/application.yaml index 673b5538..e935c571 100644 --- a/service/src/main/resources/application.yaml +++ b/service/src/main/resources/application.yaml @@ -23,7 +23,7 @@ management: show-details: always payment: - api-base-url: ${PAYMENT_SERVER_ENDPOINT:/https://api.tosspayments.com} + api-base-url: ${PAYMENT_SERVER_ENDPOINT:https://api.tosspayments.com} springdoc: swagger-ui: -- 2.47.2 From 97be1b8a1f082858f0e1c38a81a7adf197d40002 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:56:16 +0900 Subject: [PATCH 44/45] =?UTF-8?q?refactor:=20PaymentService=20/=20PaymentC?= =?UTF-8?q?lient=EC=97=90=EC=84=9C=EC=9D=98=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/business/PaymentService.kt | 39 ++++++++++--------- .../infrastructure/client/TosspayClient.kt | 10 ++++- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index 7985b20d..99794588 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -2,15 +2,10 @@ package com.sangdol.roomescape.payment.business import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode -import com.sangdol.roomescape.payment.dto.PaymentCancelRequest +import com.sangdol.roomescape.payment.dto.* import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse -import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest -import com.sangdol.roomescape.payment.dto.PaymentCreateResponse -import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse -import com.sangdol.roomescape.payment.dto.PaymentResponse import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.mapper.toResponse @@ -36,20 +31,28 @@ class PaymentService( return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also { log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" } } - } catch (e: ExternalPaymentException) { - val errorCode = if (e.httpStatusCode in 400..<500) { - PaymentErrorCode.PAYMENT_CLIENT_ERROR - } else { - PaymentErrorCode.PAYMENT_PROVIDER_ERROR - } + } catch (e: Exception) { + when(e) { + is ExternalPaymentException -> { + val errorCode = if (e.httpStatusCode in 400..<500) { + PaymentErrorCode.PAYMENT_CLIENT_ERROR + } else { + PaymentErrorCode.PAYMENT_PROVIDER_ERROR + } - val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) { - "${errorCode.message}(${e.message})" - } else { - errorCode.message - } + val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) { + "${errorCode.message}(${e.message})" + } else { + errorCode.message + } - throw PaymentException(errorCode, message) + throw PaymentException(errorCode, message) + } + else -> { + log.warn(e) { "[requestConfirm] 예상치 못한 결제 실패: paymentKey=${request.paymentKey}" } + throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } + } } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt index f296fc38..d769bb35 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/infrastructure/client/TosspayClient.kt @@ -142,10 +142,16 @@ private class TosspayErrorHandler( ): Nothing { val requestType: String = paymentRequestType(url) val errorResponse: TosspayErrorResponse = parseResponse(response) - log.warn { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" } + val status = response.statusCode + + if (status.is5xxServerError) { + log.warn { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" } + } else { + log.info { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" } + } throw ExternalPaymentException( - httpStatusCode = response.statusCode.value(), + httpStatusCode = status.value(), errorCode = errorResponse.code, message = errorResponse.message ) -- 2.47.2 From 216bde2d255e1c26fecd6a0f17e28650f7de20b0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 15:56:38 +0900 Subject: [PATCH 45/45] =?UTF-8?q?refactor:=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20DTO=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B0=EC=A0=9C=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD=20=EC=82=AC=ED=95=AD=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/payment/PaymentTypes.ts | 1 - frontend/src/api/schedule/scheduleTypes.ts | 37 ++++++++--- frontend/src/pages/ReservationStep1Page.tsx | 58 ++++++++--------- frontend/src/pages/ReservationStep2Page.tsx | 71 ++++++++++++++------- 4 files changed, 104 insertions(+), 63 deletions(-) diff --git a/frontend/src/api/payment/PaymentTypes.ts b/frontend/src/api/payment/PaymentTypes.ts index c35958ba..acd34728 100644 --- a/frontend/src/api/payment/PaymentTypes.ts +++ b/frontend/src/api/payment/PaymentTypes.ts @@ -2,7 +2,6 @@ export interface PaymentConfirmRequest { paymentKey: string; orderId: string; amount: number; - paymentType: PaymentType; } export interface PaymentCancelRequest { diff --git a/frontend/src/api/schedule/scheduleTypes.ts b/frontend/src/api/schedule/scheduleTypes.ts index 0acf9f64..b3c3042d 100644 --- a/frontend/src/api/schedule/scheduleTypes.ts +++ b/frontend/src/api/schedule/scheduleTypes.ts @@ -1,5 +1,3 @@ -import type { Difficulty } from '@_api/theme/themeTypes'; - export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED'; export const ScheduleStatus = { @@ -40,16 +38,35 @@ export interface AdminScheduleSummaryListResponse { } // Public +export interface ScheduleResponse { + id: string; + date: string; + startFrom: string; + endAt: string; + status: ScheduleStatus; +} + +export interface ScheduleThemeInfo { + id: string; + name: string; +} + +export interface ScheduleStoreInfo { + id: string; + name: string; +} + +export interface ScheduleWithStoreAndThemeResponse { + schedule: ScheduleResponse, + theme: ScheduleThemeInfo, + store: ScheduleStoreInfo, +} + export interface ScheduleWithThemeResponse { - id: string, - startFrom: string, - endAt: string, - themeId: string, - themeName: string, - themeDifficulty: Difficulty, - status: ScheduleStatus + schedule: ScheduleResponse, + theme: ScheduleThemeInfo } export interface ScheduleWithThemeListResponse { schedules: ScheduleWithThemeResponse[]; -} \ No newline at end of file +} diff --git a/frontend/src/pages/ReservationStep1Page.tsx b/frontend/src/pages/ReservationStep1Page.tsx index 9ed6fd7e..fcaa4018 100644 --- a/frontend/src/pages/ReservationStep1Page.tsx +++ b/frontend/src/pages/ReservationStep1Page.tsx @@ -1,17 +1,17 @@ -import {isLoginRequiredError} from '@_api/apiClient'; -import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI'; -import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes'; -import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI'; -import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes'; -import {getStores} from '@_api/store/storeAPI'; -import {type SimpleStoreResponse} from '@_api/store/storeTypes'; -import {fetchThemeById} from '@_api/theme/themeAPI'; -import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes'; +import { isLoginRequiredError } from '@_api/apiClient'; +import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI'; +import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes'; +import { type ReservationData } from '@_api/reservation/reservationTypes'; +import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI'; +import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes'; +import { getStores } from '@_api/store/storeAPI'; +import { type SimpleStoreResponse } from '@_api/store/storeTypes'; +import { fetchThemeById } from '@_api/theme/themeAPI'; +import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes'; import '@_css/reservation-v2-1.css'; -import React, {useEffect, useState} from 'react'; -import {useLocation, useNavigate} from 'react-router-dom'; -import {type ReservationData} from '@_api/reservation/reservationTypes'; -import {formatDate} from 'src/util/DateTimeFormatter'; +import React, { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { formatDate } from 'src/util/DateTimeFormatter'; const ReservationStep1Page: React.FC = () => { const [selectedDate, setSelectedDate] = useState(new Date()); @@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => { fetchSchedules(selectedStore.id, dateStr) .then(res => { const grouped = res.schedules.reduce((acc, schedule) => { - const key = schedule.themeName; + const key = schedule.theme.name; if (!acc[key]) acc[key] = []; acc[key].push(schedule); return acc; @@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => { const handleConfirmReservation = () => { if (!selectedSchedule) return; - holdSchedule(selectedSchedule.id) + holdSchedule(selectedSchedule.schedule.id) .then(() => { - fetchThemeById(selectedSchedule.themeId).then(res => { + fetchThemeById(selectedSchedule.theme.id).then(res => { const reservationData: ReservationData = { - scheduleId: selectedSchedule.id, + scheduleId: selectedSchedule.schedule.id, store: { id: selectedStore!.id, name: selectedStore!.name, @@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => { maxParticipants: res.maxParticipants, }, date: selectedDate.toLocaleDateString('en-CA'), - startFrom: selectedSchedule.startFrom, - endAt: selectedSchedule.endAt, + startFrom: selectedSchedule.schedule.startFrom, + endAt: selectedSchedule.schedule.endAt, }; navigate('/reservation/form', {state: reservationData}); }).catch(handleError); @@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {

3. 시간 선택

{Object.keys(schedulesByTheme).length > 0 ? ( - Object.entries(schedulesByTheme).map(([themeName, schedules]) => ( + Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (

{themeName}

-
- {schedules.map(schedule => ( + {scheduleAndTheme.map(schedule => (
schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)} + key={schedule.schedule.id} + className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`} + onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)} > - {`${schedule.startFrom} ~ ${schedule.endAt}`} - {getStatusText(schedule.status)} + {`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`} + {getStatusText(schedule.schedule.status)}
))}
@@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {

날짜:{formatDate(selectedDate.toLocaleDateString('ko-KR'))}

매장:{selectedStore?.name}

-

테마:{selectedSchedule.themeName}

-

시간:{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}

+

테마:{selectedSchedule.theme.name}

+

시간:{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}

diff --git a/frontend/src/pages/ReservationStep2Page.tsx b/frontend/src/pages/ReservationStep2Page.tsx index 6a6ad964..38bf1e49 100644 --- a/frontend/src/pages/ReservationStep2Page.tsx +++ b/frontend/src/pages/ReservationStep2Page.tsx @@ -1,8 +1,9 @@ -import { isLoginRequiredError } from '@_api/apiClient'; -import { confirmPayment } from '@_api/payment/paymentAPI'; -import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes'; +import { confirm } from '@_api/order/orderAPI'; +import type { BookingErrorResponse } from '@_api/order/orderTypes'; +import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes'; import { confirmReservation } from '@_api/reservation/reservationAPI'; import '@_css/reservation-v2-1.css'; +import type { AxiosError } from 'axios'; import React, { useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { formatDate } from 'src/util/DateTimeFormatter'; @@ -21,17 +22,6 @@ const ReservationStep2Page: React.FC = () => { const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {}; - const handleError = (err: any) => { - if (isLoginRequiredError(err)) { - alert('로그인이 필요해요.'); - navigate('/login', { state: { from: location } }); - } else { - const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; - alert(message); - console.error(err); - } - }; - useEffect(() => { if (!reservationId) { alert('잘못된 접근입니다.'); @@ -66,7 +56,7 @@ const ReservationStep2Page: React.FC = () => { const generateRandomString = () => crypto.randomUUID().replace(/-/g, ''); - + paymentWidgetRef.current.requestPayment({ orderId: generateRandomString(), @@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => { paymentKey: data.paymentKey, orderId: data.orderId, amount: totalPrice, - paymentType: data.paymentType || PaymentType.NORMAL, }; - - confirmPayment(reservationId, paymentData) - .then(() => { - return confirmReservation(reservationId); - }) + confirm(reservationId, paymentData) .then(() => { alert('결제가 완료되었어요!'); navigate('/reservation/success', { @@ -97,10 +82,50 @@ const ReservationStep2Page: React.FC = () => { } }); }) - .catch(handleError); + .catch(err => { + const error = err as AxiosError; + const errorCode = error.response?.data?.code; + const errorMessage = error.response?.data?.message; + + if (errorCode === 'B000') { + alert(`예약을 완료할 수 없어요.(${errorMessage})`); + navigate('/reservation'); + return; + } + + const trial = error.response?.data?.trial || 0; + if (trial < 2) { + alert(errorMessage); + return; + } + alert(errorMessage); + + setTimeout(() => { + const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?'); + + if (agreeToOnsitePayment) { + confirmReservation(reservationId) + .then(() => { + navigate('/reservation/success', { + state: { + storeName, + themeName, + date, + time, + participantCount, + totalPrice, + }, + }); + }); + } else { + alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.'); + navigate('/'); + } + }, 100); + }); }).catch((error: any) => { console.error("Payment request error:", error); - alert("결제 요청 중 오류가 발생했습니다."); + alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요."); }); }; -- 2.47.2