[#30] 코드 구조 개선 #31

Merged
pricelees merged 31 commits from refactor/#30 into main 2025-08-06 10:16:08 +00:00
11 changed files with 189 additions and 662 deletions
Showing only changes of commit d1772dfcc5 - Show all commits

View File

@ -74,6 +74,7 @@ class PaymentWriter(
return canceledPaymentRepository.save(canceledPayment) return canceledPaymentRepository.save(canceledPayment)
.also { .also {
paymentRepository.deleteByPaymentKey(paymentKey)
log.debug { "[PaymentWriter.createCanceled] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" } log.debug { "[PaymentWriter.createCanceled] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" }
} }
} }

View File

@ -8,5 +8,5 @@ interface PaymentRepository : JpaRepository<PaymentEntity, Long> {
fun findByReservationId(reservationId: Long): PaymentEntity? fun findByReservationId(reservationId: Long): PaymentEntity?
fun findByPaymentKey(paymentKey: String): PaymentEntity? fun deleteByPaymentKey(paymentKey: String)
} }

View File

@ -0,0 +1,104 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.implement.ReservationWriter
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.*
private val log: KLogger = KotlinLogging.logger {}
@Service
@Transactional
class ReservationCommandService(
private val reservationFinder: ReservationFinder,
private val reservationWriter: ReservationWriter
) {
fun createReservationWithPayment(
request: ReservationCreateWithPaymentRequest,
memberId: Long
): ReservationEntity {
log.info { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED,
memberId = memberId,
requesterId = memberId
)
return created.also {
log.info { "[ReservationCommandService.createReservationByAdmin] 완료: reservationId=${it.id}" }
}
}
fun createReservationByAdmin(
request: AdminReservationCreateRequest,
memberId: Long
): ReservationCreateResponse {
log.info { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${request.memberId} by adminId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED,
memberId = request.memberId,
requesterId = memberId
)
return created.toCreateResponse()
.also {
log.info { "[ReservationCommandService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" }
}
}
fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationCreateResponse {
log.info { "[ReservationCommandService.createWaiting] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.WAITING,
memberId = memberId,
requesterId = memberId
)
return created.toCreateResponse()
.also {
log.info { "[ReservationCommandService.createWaiting] 완료: reservationId=${it.id}" }
}
}
fun deleteReservation(reservationId: Long, memberId: Long) {
log.debug { "[ReservationCommandService.deleteReservation] 시작: reservationId=${reservationId}, memberId=$memberId" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
reservationWriter.deleteConfirmed(reservation, requesterId = memberId)
.also { log.info { "[ReservationCommandService.deleteReservation] 완료: reservationId=${reservationId}" } }
}
fun confirmWaiting(reservationId: Long, memberId: Long) {
log.info { "[ReservationCommandService.confirmWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" }
reservationWriter.confirm(reservationId)
.also { log.info { "[ReservationCommandService.confirmWaiting] 완료: reservationId=$reservationId" } }
}
fun deleteWaiting(reservationId: Long, memberId: Long) {
log.info { "[ReservationCommandService.deleteWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
reservationWriter.deleteWaiting(reservation, requesterId = memberId)
.also { log.info { "[ReservationCommandService.deleteWaiting] 완료: reservationId=$reservationId" } }
}
}

View File

@ -0,0 +1,56 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.MyReservationRetrieveListResponse
import roomescape.reservation.web.ReservationRetrieveListResponse
import roomescape.reservation.web.toRetrieveListResponse
import java.time.LocalDate
private val log = KotlinLogging.logger {}
@Service
@Transactional(readOnly = true)
class ReservationQueryService(
private val reservationFinder: ReservationFinder
) {
fun findReservations(): ReservationRetrieveListResponse {
log.info { "[ReservationService.findReservations] 시작" }
return reservationFinder.findAllByStatuses(*ReservationStatus.confirmedStatus())
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findReservations] ${it.reservations.size}개의 예약 조회 완료" } }
}
fun findAllWaiting(): ReservationRetrieveListResponse {
log.info { "[ReservationService.findAllWaiting] 시작" }
return reservationFinder.findAllByStatuses(ReservationStatus.WAITING)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findAllWaiting] ${it.reservations.size}개의 대기 조회 완료" } }
}
fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse {
log.info { "[ReservationService.findReservationsByMemberId] 시작: memberId=$memberId" }
return reservationFinder.findAllByMemberId(memberId)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findReservationsByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=$memberId" } }
}
fun searchReservations(
themeId: Long?,
memberId: Long?,
startFrom: LocalDate?,
endAt: LocalDate?,
): ReservationRetrieveListResponse {
log.info { "[ReservationService.searchReservations] 시작: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" }
return reservationFinder.searchReservations(themeId, memberId, startFrom, endAt)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.searchReservations] ${it.reservations.size}개의 예약 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" } }
}
}

View File

@ -1,322 +0,0 @@
package roomescape.reservation.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.*
import roomescape.theme.business.ThemeService
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.business.TimeService
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
import java.time.LocalDateTime
private val log = KotlinLogging.logger {}
@Service
@Transactional
class ReservationService(
private val tsidFactory: TsidFactory,
private val reservationRepository: ReservationRepository,
private val timeService: TimeService,
private val memberService: MemberService,
private val themeService: ThemeService,
) {
@Transactional(readOnly = true)
fun findReservations(): ReservationRetrieveListResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.build()
val reservations = findAllReservationByStatus(spec)
log.info { "[ReservationService.findReservations] ${reservations.size} 개의 확정 예약 조회 완료" }
return ReservationRetrieveListResponse(reservations)
}
@Transactional(readOnly = true)
fun findAllWaiting(): ReservationRetrieveListResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.waiting()
.build()
val reservations = findAllReservationByStatus(spec)
log.info { "[ReservationService.findAllWaiting] ${reservations.size} 개의 대기 예약 조회 완료" }
return ReservationRetrieveListResponse(reservations)
}
private fun findAllReservationByStatus(spec: Specification<ReservationEntity>): List<ReservationRetrieveResponse> {
return reservationRepository.findAll(spec).map { it.toRetrieveResponse() }
}
fun deleteReservation(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId, "deleteReservation")
log.debug { "[ReservationService.deleteReservation] 예약 삭제 시작: reservationId=$reservationId, memberId=$memberId" }
reservationRepository.deleteById(reservationId)
log.info { "[ReservationService.deleteReservation] 예약 삭제 완료: reservationId=$reservationId" }
}
fun createConfirmedReservation(
request: ReservationCreateWithPaymentRequest,
memberId: Long,
): ReservationEntity {
val themeId = request.themeId
val timeId = request.timeId
val date: LocalDate = request.date
validateIsReservationExist(themeId, timeId, date, "createConfirmedReservation")
log.debug { "[ReservationService.createConfirmedReservation] 예약 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
val reservation: ReservationEntity =
createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED)
return reservationRepository.save(reservation)
.also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.status}" } }
}
fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse {
validateIsReservationExist(request.themeId, request.timeId, request.date)
log.debug { "[ReservationService.createReservationByAdmin] 관리자의 예약 추가: memberId=${request.memberId}, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
return addReservationWithoutPayment(
request.themeId,
request.timeId,
request.date,
request.memberId,
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
).also {
log.info { "[ReservationService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" }
}
}
fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationRetrieveResponse {
validateMemberAlreadyReserve(request.themeId, request.timeId, request.date, memberId)
log.debug { "[ReservationService.createWaiting] 예약 대기 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
return addReservationWithoutPayment(
request.themeId,
request.timeId,
request.date,
memberId,
ReservationStatus.WAITING
).also {
log.info { "[ReservationService.createWaiting] 예약 대기 추가 완료: reservationId=${it.id}, status=${it.status}" }
}
}
private fun addReservationWithoutPayment(
themeId: Long,
timeId: Long,
date: LocalDate,
memberId: Long,
status: ReservationStatus,
): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status)
.also {
reservationRepository.save(it)
}.toRetrieveResponse()
private fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, memberId: Long) {
log.debug {
"[ReservationService.validateMemberAlreadyReserve] 회원의 중복 예약 여부 확인: themeId=$themeId, timeId=$timeId, date=$date, memberId=$memberId"
}
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameMemberId(memberId)
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationService.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.ALREADY_RESERVE)
}
}
private fun validateIsReservationExist(
themeId: Long,
timeId: Long,
date: LocalDate,
calledBy: String = "validateIsReservationExist"
) {
log.debug {
"[ReservationService.$calledBy] 예약 존재 여부 확인: themeId=$themeId, timeId=$timeId, date=$date"
}
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationService.$calledBy] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED)
}
}
private fun validateDateAndTime(
requestDate: LocalDate,
requestTime: TimeEntity,
) {
val now = LocalDateTime.now()
val request = LocalDateTime.of(requestDate, requestTime.startAt)
if (request.isBefore(now)) {
log.info { "[ReservationService.validateDateAndTime] 날짜 범위 오류. request=$request, now=$now" }
throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME)
}
}
private fun createEntity(
timeId: Long,
themeId: Long,
date: LocalDate,
memberId: Long,
status: ReservationStatus,
): ReservationEntity {
val time: TimeEntity = timeService.findById(timeId)
val theme: ThemeEntity = themeService.findById(themeId)
val member: MemberEntity = memberService.findById(memberId)
validateDateAndTime(date, time)
return ReservationEntity(
_id = tsidFactory.next(),
date = date,
time = time,
theme = theme,
member = member,
status = status
)
}
@Transactional(readOnly = true)
fun searchReservations(
themeId: Long?,
memberId: Long?,
dateFrom: LocalDate?,
dateTo: LocalDate?,
): ReservationRetrieveListResponse {
log.debug { "[ReservationService.searchReservations] 예약 검색 시작: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" }
validateSearchDateRange(dateFrom, dateTo)
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameMemberId(memberId)
.dateStartFrom(dateFrom)
.dateEndAt(dateTo)
.build()
val reservations = findAllReservationByStatus(spec)
return ReservationRetrieveListResponse(reservations)
.also { log.info { "[ReservationService.searchReservations] 예약 ${reservations.size}개 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" } }
}
private fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) {
if (startFrom == null || endAt == null) {
return
}
if (startFrom.isAfter(endAt)) {
log.info { "[ReservationService.validateSearchDateRange] 조회 범위 오류: startFrom=$startFrom, endAt=$endAt" }
throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE)
}
}
@Transactional(readOnly = true)
fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse {
val reservations = reservationRepository.findAllByMemberId(memberId)
log.info { "[ReservationService.findReservationsByMemberId] memberId=${memberId}${reservations.size}개의 예약 조회 완료" }
return MyReservationRetrieveListResponse(reservations)
}
fun confirmWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationService.confirmWaiting] 대기 예약 승인 시작: reservationId=$reservationId (by adminId=$memberId)" }
validateIsMemberAdmin(memberId, "confirmWaiting")
log.debug { "[ReservationService.confirmWaiting] 대기 여부 확인 시작: reservationId=$reservationId" }
if (reservationRepository.isExistConfirmedReservation(reservationId)) {
log.warn { "[ReservationService.confirmWaiting] 승인 실패(이미 확정된 예약 존재): reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS)
}
log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 시작: reservationId=$reservationId" }
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" }
log.info { "[ReservationService.confirmWaiting] 대기 예약 승인 완료: reservationId=$reservationId" }
}
fun deleteWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationService.deleteWaiting] 대기 취소 시작: reservationId=$reservationId, memberId=$memberId" }
val reservation: ReservationEntity = findReservationOrThrow(reservationId, "deleteWaiting")
if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.deleteWaiting] 대기 취소 실패(대기 예약이 아님): reservationId=$reservationId" +
", currentStatus=${reservation.status} memberId=$memberId"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}
if (!reservation.isReservedBy(memberId)) {
log.error {
"[ReservationService.deleteWaiting] 대기 취소 실패(예약자 본인의 취소 요청이 아님): reservationId=$reservationId" +
", memberId=$memberId "
}
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 시작: reservationId=$reservationId" }
reservationRepository.delete(reservation)
log.info { "[ReservationService.deleteWaiting] 대기 취소 완료: reservationId=$reservationId, memberId=$memberId" }
}
fun rejectWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId, "rejectWaiting")
log.debug { "[ReservationService.rejectWaiting] 대기 예약 삭제 시작: reservationId=$reservationId (by adminId=$memberId)" }
val reservation: ReservationEntity = findReservationOrThrow(reservationId, "rejectWaiting")
if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.rejectWaiting] 대기 예약 삭제 실패(이미 확정 상태): reservationId=$reservationId" +
", status=${reservation.status}"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}
reservationRepository.delete(reservation)
log.info { "[ReservationService.rejectWaiting] 대기 예약 삭제 완료: reservationId=$reservationId" }
}
private fun validateIsMemberAdmin(memberId: Long, calledBy: String = "validateIsMemberAdmin") {
log.debug { "[ReservationService.$calledBy] 관리자 여부 확인: memberId=$memberId" }
val member: MemberEntity = memberService.findById(memberId)
if (member.isAdmin()) {
return
}
log.warn { "[ReservationService.$calledBy] 관리자가 아님: memberId=$memberId, role=${member.role}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION)
}
private fun findReservationOrThrow(
reservationId: Long,
calledBy: String = "findReservationOrThrow"
): ReservationEntity {
log.debug { "[ReservationService.$calledBy] 예약 조회: reservationId=$reservationId" }
return reservationRepository.findByIdOrNull(reservationId)
?.also { log.info { "[ReservationService.$calledBy] 예약 조회 완료: reservationId=$reservationId" } }
?: run {
log.warn { "[ReservationService.$calledBy] 예약 조회 실패: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
}

View File

@ -18,7 +18,7 @@ private val log = KotlinLogging.logger {}
@Service @Service
@Transactional @Transactional
class ReservationWithPaymentService( class ReservationWithPaymentService(
private val reservationService: ReservationService, private val reservationCommandService: ReservationCommandService,
private val paymentService: PaymentService, private val paymentService: PaymentService,
) { ) {
fun createReservationAndPayment( fun createReservationAndPayment(
@ -28,7 +28,7 @@ class ReservationWithPaymentService(
): ReservationCreateResponse { ): ReservationCreateResponse {
log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 시작: memberId=$memberId, paymentInfo=$approvedPaymentInfo" } log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 시작: memberId=$memberId, paymentInfo=$approvedPaymentInfo" }
val reservation: ReservationEntity = reservationService.createConfirmedReservation(request, memberId) val reservation: ReservationEntity = reservationCommandService.createReservationWithPayment(request, memberId)
.also { paymentService.createPayment(approvedPaymentInfo, it) } .also { paymentService.createPayment(approvedPaymentInfo, it) }
return reservation.toCreateResponse() return reservation.toCreateResponse()
@ -50,7 +50,7 @@ class ReservationWithPaymentService(
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 시작: reservationId=$reservationId" } log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 시작: reservationId=$reservationId" }
val paymentCancelRequest = paymentService.createCanceledPayment(reservationId) val paymentCancelRequest = paymentService.createCanceledPayment(reservationId)
reservationService.deleteReservation(reservationId, memberId) reservationCommandService.deleteReservation(reservationId, memberId)
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 완료: reservationId=$reservationId" } log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 완료: reservationId=$reservationId" }
return paymentCancelRequest return paymentCancelRequest
} }
@ -62,7 +62,7 @@ class ReservationWithPaymentService(
val notPaid: Boolean = !paymentService.existsByReservationId(reservationId) val notPaid: Boolean = !paymentService.existsByReservationId(reservationId)
return notPaid.also { return notPaid.also {
log.info { "[ReservationWithPaymentService.isNotPaidReservation] 완료: reservationId=$reservationId, 미결제=${notPaid}" } log.info { "[ReservationWithPaymentService.isNotPaidReservation] 완료: reservationId=$reservationId, isPaid=${notPaid}" }
} }
} }

View File

@ -92,7 +92,8 @@ interface ReservationAPI {
) )
fun createReservationByAdmin( fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest, @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@Admin @Admin
@Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"])
@ -116,7 +117,7 @@ interface ReservationAPI {
fun createWaiting( fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"])

View File

@ -1,5 +1,6 @@
package roomescape.reservation.infrastructure.persistence package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.JpaSpecificationExecutor
import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Modifying

View File

@ -10,7 +10,8 @@ import roomescape.payment.infrastructure.client.PaymentApproveRequest
import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.client.TossPaymentClient import roomescape.payment.infrastructure.client.TossPaymentClient
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.reservation.business.ReservationService import roomescape.reservation.business.ReservationCommandService
import roomescape.reservation.business.ReservationQueryService
import roomescape.reservation.business.ReservationWithPaymentService import roomescape.reservation.business.ReservationWithPaymentService
import roomescape.reservation.docs.ReservationAPI import roomescape.reservation.docs.ReservationAPI
import java.net.URI import java.net.URI
@ -19,12 +20,13 @@ import java.time.LocalDate
@RestController @RestController
class ReservationController( class ReservationController(
private val reservationWithPaymentService: ReservationWithPaymentService, private val reservationWithPaymentService: ReservationWithPaymentService,
private val reservationService: ReservationService, private val reservationQueryService: ReservationQueryService,
private val reservationCommandService: ReservationCommandService,
private val paymentClient: TossPaymentClient private val paymentClient: TossPaymentClient
) : ReservationAPI { ) : ReservationAPI {
@GetMapping("/reservations") @GetMapping("/reservations")
override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationService.findReservations() val response: ReservationRetrieveListResponse = reservationQueryService.findReservations()
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -33,7 +35,7 @@ class ReservationController(
override fun findReservationsByMemberId( override fun findReservationsByMemberId(
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> {
val response: MyReservationRetrieveListResponse = reservationService.findReservationsByMemberId(memberId) val response: MyReservationRetrieveListResponse = reservationQueryService.findReservationsByMemberId(memberId)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -45,7 +47,7 @@ class ReservationController(
@RequestParam(required = false) dateFrom: LocalDate?, @RequestParam(required = false) dateFrom: LocalDate?,
@RequestParam(required = false) dateTo: LocalDate? @RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationService.searchReservations( val response: ReservationRetrieveListResponse = reservationQueryService.searchReservations(
themeId, themeId,
memberId, memberId,
dateFrom, dateFrom,
@ -61,7 +63,7 @@ class ReservationController(
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) { if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
reservationService.deleteReservation(reservationId, memberId) reservationCommandService.deleteReservation(reservationId, memberId)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@ -110,10 +112,11 @@ class ReservationController(
@PostMapping("/reservations/admin") @PostMapping("/reservations/admin")
override fun createReservationByAdmin( override fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { @MemberId @Parameter(hidden = true) memberId: Long,
val response: ReservationRetrieveResponse = ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
reservationService.createReservationByAdmin(adminReservationRequest) val response: ReservationCreateResponse =
reservationCommandService.createReservationByAdmin(adminReservationRequest, memberId)
return ResponseEntity.created(URI.create("/reservations/${response.id}")) return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response)) .body(CommonApiResponse(response))
@ -121,7 +124,7 @@ class ReservationController(
@GetMapping("/reservations/waiting") @GetMapping("/reservations/waiting")
override fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { override fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationService.findAllWaiting() val response: ReservationRetrieveListResponse = reservationQueryService.findAllWaiting()
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -130,8 +133,8 @@ class ReservationController(
override fun createWaiting( override fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
val response: ReservationRetrieveResponse = reservationService.createWaiting( val response: ReservationCreateResponse = reservationCommandService.createWaiting(
waitingCreateRequest, waitingCreateRequest,
memberId memberId
) )
@ -145,7 +148,7 @@ class ReservationController(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.deleteWaiting(reservationId, memberId) reservationCommandService.deleteWaiting(reservationId, memberId)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@ -155,7 +158,7 @@ class ReservationController(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.confirmWaiting(reservationId, memberId) reservationCommandService.confirmWaiting(reservationId, memberId)
return ResponseEntity.ok().build() return ResponseEntity.ok().build()
} }
@ -165,7 +168,7 @@ class ReservationController(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.rejectWaiting(reservationId, memberId) reservationCommandService.deleteWaiting(reservationId, memberId)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }

View File

@ -38,36 +38,6 @@ class PaymentRepositoryTest(
.also { it shouldBe false } .also { it shouldBe false }
} }
} }
context("findByPaymentKey") {
lateinit var payment: PaymentEntity
beforeTest {
reservation = setupReservation()
payment = PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) }
}
test("정상 반환") {
paymentRepository.findByPaymentKey(payment.paymentKey)
?.also {
assertSoftly(it) {
this.id shouldBe payment.id
this.orderId shouldBe payment.orderId
this.paymentKey shouldBe payment.paymentKey
this.totalAmount shouldBe payment.totalAmount
this.reservation.id shouldBe payment.reservation.id
this.approvedAt shouldBe payment.approvedAt
}
}
?: throw AssertionError("Unexpected null value")
}
test("null 반환") {
paymentRepository.findByPaymentKey("non-existent-key")
.also { it shouldBe null }
}
}
} }
private fun setupReservation(): ReservationEntity { private fun setupReservation(): ReservationEntity {

View File

@ -1,287 +0,0 @@
package roomescape.reservation.business
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Role
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.business.ThemeService
import roomescape.time.business.TimeService
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.TsidFactory
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
class ReservationServiceTest : FunSpec({
val reservationRepository: ReservationRepository = mockk()
val timeService: TimeService = mockk()
val memberService: MemberService = mockk()
val themeService: ThemeService = mockk()
val reservationService = ReservationService(
TsidFactory,
reservationRepository,
timeService,
memberService,
themeService
)
context("예약을 추가할 때") {
test("이미 예약이 있으면 예외를 던진다.") {
every {
reservationRepository.exists(any())
} returns true
val reservationRequest = ReservationFixture.createRequest()
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED
}
}
context("날짜, 시간이 잘못 입력되면 예외를 던진다.") {
every {
reservationRepository.exists(any())
} returns false
every {
themeService.findById(any())
} returns mockk()
every {
memberService.findById(any())
} returns mockk()
test("지난 날짜이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now().minusDays(1)
)
every {
timeService.findById(any())
} returns TimeFixture.create()
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
test("지난 시간이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(),
)
every {
timeService.findById(reservationRequest.timeId)
} returns TimeFixture.create(
startAt = LocalTime.now().minusMinutes(1)
)
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
}
}
context("예약 대기를 걸 때") {
test("이미 예약한 회원이 같은 날짜와 테마로 대기를 걸면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(),
themeId = 1L,
timeId = 1L,
)
every {
reservationRepository.exists(any())
} returns true
shouldThrow<ReservationException> {
val waitingRequest = ReservationFixture.createWaitingRequest(
date = reservationRequest.date,
themeId = reservationRequest.themeId,
timeId = reservationRequest.timeId
)
reservationService.createWaiting(waitingRequest, 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE
}
}
}
context("예약 대기를 취소할 때") {
val reservationId = 1L
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
test("예약을 찾을 수 없으면 예외를 던진다.") {
every {
reservationRepository.findByIdOrNull(reservationId)
} returns null
shouldThrow<ReservationException> {
reservationService.deleteWaiting(reservationId, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND
}
}
test("대기중인 해당 예약이 이미 확정된 상태라면 예외를 던진다.") {
val alreadyConfirmed = ReservationFixture.create(
id = reservationId,
status = ReservationStatus.CONFIRMED
)
every {
reservationRepository.findByIdOrNull(reservationId)
} returns alreadyConfirmed
shouldThrow<ReservationException> {
reservationService.deleteWaiting(reservationId, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED
}
}
test("타인의 대기를 취소하려고 하면 예외를 던진다.") {
val otherMembersWaiting = ReservationFixture.create(
id = reservationId,
member = MemberFixture.create(id = member.id!! + 1L),
status = ReservationStatus.WAITING
)
every {
reservationRepository.findByIdOrNull(reservationId)
} returns otherMembersWaiting
shouldThrow<ReservationException> {
reservationService.deleteWaiting(reservationId, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER
}
}
}
context("예약을 조회할 때") {
test("종료 날짜가 시작 날짜보다 이전이면 예외를 던진다.") {
val startFrom = LocalDate.now()
val endAt = startFrom.minusDays(1)
shouldThrow<ReservationException> {
reservationService.searchReservations(
null,
null,
startFrom,
endAt
)
}.also {
it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
}
}
}
context("대기중인 예약을 승인할 때") {
test("관리자가 아니면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
every {
memberService.findById(any())
} returns member
shouldThrow<ReservationException> {
reservationService.confirmWaiting(1L, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION
}
}
test("이미 확정된 예약이 있으면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.ADMIN)
val reservationId = 1L
every {
memberService.findById(any())
} returns member
every {
reservationRepository.isExistConfirmedReservation(reservationId)
} returns true
shouldThrow<ReservationException> {
reservationService.confirmWaiting(reservationId, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
}
}
}
context("대기중인 예약을 거절할 때") {
test("관리자가 아니면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
every {
memberService.findById(any())
} returns member
shouldThrow<ReservationException> {
reservationService.rejectWaiting(1L, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION
}
}
test("예약을 찾을 수 없으면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.ADMIN)
val reservationId = 1L
every {
memberService.findById(member.id!!)
} returns member
every {
reservationRepository.findByIdOrNull(reservationId)
} returns null
shouldThrow<ReservationException> {
reservationService.rejectWaiting(reservationId, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND
}
}
test("이미 확정된 예약이면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.ADMIN)
val reservation = ReservationFixture.create(
id = 1L,
status = ReservationStatus.CONFIRMED
)
every {
memberService.findById(member.id!!)
} returns member
every {
reservationRepository.findByIdOrNull(reservation.id!!)
} returns reservation
shouldThrow<ReservationException> {
reservationService.rejectWaiting(reservation.id!!, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED
}
}
}
})