From 25b3bcb534c33a87d7ae46c8245d8e0c985c8ffe Mon Sep 17 00:00:00 2001
From: Klaus <klaus@vividnext.co.kr>
Date: Wed, 2 Aug 2023 19:11:25 +0900
Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=98=88?=
 =?UTF-8?q?=EC=95=BD=20=EC=B7=A8=EC=86=8C=20API?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../sodalive/can/payment/CanPaymentService.kt |  41 +++++++
 .../CancelLiveReservationRequest.kt           |   3 +
 .../reservation/GetLiveReservationResponse.kt |  12 ++
 .../live/reservation/LiveReservationCancel.kt |  16 +++
 .../LiveReservationCancelRepository.kt        |   7 ++
 .../reservation/LiveReservationController.kt  |  33 ++++++
 .../reservation/LiveReservationRepository.kt  |  30 +++++
 .../reservation/LiveReservationService.kt     | 104 +++++++++++++++++-
 .../sodalive/live/room/LiveRoomService.kt     |   8 +-
 9 files changed, 249 insertions(+), 5 deletions(-)
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/CancelLiveReservationRequest.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/GetLiveReservationResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancel.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancelRepository.kt

diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt
index 434ced3..82885d9 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt
@@ -1,6 +1,9 @@
 package kr.co.vividnext.sodalive.can.payment
 
+import kr.co.vividnext.sodalive.can.CanRepository
+import kr.co.vividnext.sodalive.can.charge.Charge
 import kr.co.vividnext.sodalive.can.charge.ChargeRepository
+import kr.co.vividnext.sodalive.can.charge.ChargeStatus
 import kr.co.vividnext.sodalive.can.use.CanUsage
 import kr.co.vividnext.sodalive.can.use.SpentCan
 import kr.co.vividnext.sodalive.can.use.TotalSpentCan
@@ -18,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional
 
 @Service
 class CanPaymentService(
+    private val repository: CanRepository,
     private val memberRepository: MemberRepository,
     private val chargeRepository: ChargeRepository,
     private val useCanRepository: UseCanRepository,
@@ -242,4 +246,41 @@ class CanPaymentService(
             TotalSpentCan(total = 0)
         }
     }
+
+    @Transactional
+    fun refund(memberId: Long, roomId: Long) {
+        val member = memberRepository.findByIdOrNull(memberId)
+            ?: throw SodaException("잘못된 예약정보 입니다.")
+
+        val useCan = repository.getCanUsedForLiveRoomNotRefund(
+            memberId = memberId,
+            roomId = roomId,
+            canUsage = CanUsage.LIVE
+        ) ?: throw SodaException("잘못된 예약정보 입니다.")
+        useCan.isRefund = true
+
+        val useCoinCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
+        useCoinCalculates.forEach {
+            it.status = UseCanCalculateStatus.REFUND
+            val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
+            charge.title = "${it.can} 캔"
+            charge.useCan = useCan
+
+            when (it.paymentGateway) {
+                PaymentGateway.PG -> member.pgRewardCan += charge.rewardCan
+                PaymentGateway.GOOGLE_IAP -> member.googleRewardCan += charge.rewardCan
+                PaymentGateway.APPLE_IAP -> member.appleRewardCan += charge.rewardCan
+            }
+            charge.member = member
+
+            val payment = Payment(
+                status = PaymentStatus.COMPLETE,
+                paymentGateway = it.paymentGateway
+            )
+            payment.method = "환불"
+            charge.payment = payment
+
+            chargeRepository.save(charge)
+        }
+    }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/CancelLiveReservationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/CancelLiveReservationRequest.kt
new file mode 100644
index 0000000..edcf504
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/CancelLiveReservationRequest.kt
@@ -0,0 +1,3 @@
+package kr.co.vividnext.sodalive.live.reservation
+
+data class CancelLiveReservationRequest(val reservationId: Long, val reason: String)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/GetLiveReservationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/GetLiveReservationResponse.kt
new file mode 100644
index 0000000..65627d1
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/GetLiveReservationResponse.kt
@@ -0,0 +1,12 @@
+package kr.co.vividnext.sodalive.live.reservation
+
+data class GetLiveReservationResponse(
+    val reservationId: Long,
+    val roomId: Long,
+    val title: String,
+    val coverImageUrl: String,
+    val price: Int,
+    val masterNickname: String,
+    val beginDateTime: String,
+    val cancelable: Boolean
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancel.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancel.kt
new file mode 100644
index 0000000..1154988
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancel.kt
@@ -0,0 +1,16 @@
+package kr.co.vividnext.sodalive.live.reservation
+
+import kr.co.vividnext.sodalive.common.BaseEntity
+import javax.persistence.Entity
+import javax.persistence.FetchType
+import javax.persistence.JoinColumn
+import javax.persistence.OneToOne
+
+@Entity
+data class LiveReservationCancel(
+    val reason: String
+) : BaseEntity() {
+    @OneToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "reservation_id", nullable = false)
+    var reservation: LiveReservation? = null
+}
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancelRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancelRepository.kt
new file mode 100644
index 0000000..e5eebe3
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationCancelRepository.kt
@@ -0,0 +1,7 @@
+package kr.co.vividnext.sodalive.live.reservation
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+@Repository
+interface LiveReservationCancelRepository : JpaRepository<LiveReservationCancel, Long>
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt
index a0abd2c..ead7b72 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt
@@ -4,9 +4,13 @@ import kr.co.vividnext.sodalive.common.ApiResponse
 import kr.co.vividnext.sodalive.common.SodaException
 import kr.co.vividnext.sodalive.member.Member
 import org.springframework.security.core.annotation.AuthenticationPrincipal
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
 import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.PutMapping
 import org.springframework.web.bind.annotation.RequestBody
 import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
 import org.springframework.web.bind.annotation.RestController
 
 @RestController
@@ -21,4 +25,33 @@ class LiveReservationController(private val service: LiveReservationService) {
 
         ApiResponse.ok(service.makeReservation(request, member.id!!))
     }
+
+    @GetMapping
+    fun getReservationList(
+        @RequestParam isActive: Boolean,
+        @RequestParam(value = "timezone") timezone: String,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.getReservationList(member.id!!, isActive, timezone))
+    }
+
+    @GetMapping("/{id}")
+    fun getReservation(
+        @PathVariable id: Long,
+        @RequestParam(value = "timezone") timezone: String,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.getReservation(id, member.id!!, timezone))
+    }
+
+    @PutMapping("/cancel")
+    fun cancelReservation(
+        @RequestBody request: CancelLiveReservationRequest,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.cancelReservation(request, member.id!!))
+    }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt
index ecaf7d2..07a396f 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt
@@ -19,6 +19,12 @@ interface LiveReservationQueryRepository {
     fun getReservationBookerList(roomId: Long): List<Member>
 
     fun isExistsReservation(roomId: Long, memberId: Long): Boolean
+    fun getReservationListByMemberId(memberId: Long, active: Boolean): List<LiveReservation>
+
+    fun getReservationByReservationAndMemberId(
+        reservationId: Long,
+        memberId: Long
+    ): LiveReservation?
 }
 
 @Repository
@@ -67,4 +73,28 @@ class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFacto
             )
             .fetchFirst() != null
     }
+
+    override fun getReservationListByMemberId(memberId: Long, active: Boolean): List<LiveReservation> {
+        return queryFactory
+            .selectFrom(liveReservation)
+            .innerJoin(liveReservation.member, member)
+            .innerJoin(liveReservation.room, liveRoom)
+            .where(
+                liveReservation.isActive.eq(active)
+                    .and(member.id.eq(memberId))
+            )
+            .fetch()
+    }
+
+    override fun getReservationByReservationAndMemberId(reservationId: Long, memberId: Long): LiveReservation? {
+        return queryFactory
+            .selectFrom(liveReservation)
+            .innerJoin(liveReservation.member, member)
+            .innerJoin(liveReservation.room, liveRoom)
+            .where(
+                liveReservation.id.eq(reservationId)
+                    .and(member.id.eq(memberId))
+            )
+            .fetchFirst()
+    }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt
index 9184f72..df2a75d 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt
@@ -6,8 +6,11 @@ import kr.co.vividnext.sodalive.common.SodaException
 import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
 import kr.co.vividnext.sodalive.live.room.LiveRoomType
 import kr.co.vividnext.sodalive.member.MemberRepository
+import org.springframework.beans.factory.annotation.Value
 import org.springframework.data.repository.findByIdOrNull
 import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import java.time.LocalDateTime
 import java.time.ZoneId
 import java.time.format.DateTimeFormatter
 
@@ -16,7 +19,11 @@ class LiveReservationService(
     private val repository: LiveReservationRepository,
     private val liveRoomRepository: LiveRoomRepository,
     private val memberRepository: MemberRepository,
-    private val canPaymentService: CanPaymentService
+    private val canPaymentService: CanPaymentService,
+    private val liveReservationCancelRepository: LiveReservationCancelRepository,
+
+    @Value("\${cloud.aws.cloud-front.host}")
+    private val cloudFrontHost: String
 ) {
     fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
         val room = liveRoomRepository.findByIdOrNull(id = request.roomId)
@@ -76,4 +83,99 @@ class LiveReservationService(
             remainingCan = haveCan - room.price
         )
     }
+
+    fun getReservationList(memberId: Long, active: Boolean, timezone: String): List<GetLiveReservationResponse> {
+        return repository
+            .getReservationListByMemberId(memberId, active)
+            .asSequence()
+            .map {
+                val beginDateTime = it.room!!.beginDateTime
+                    .atZone(ZoneId.of("UTC"))
+                    .withZoneSameInstant(ZoneId.of(timezone))
+
+                GetLiveReservationResponse(
+                    reservationId = it.id!!,
+                    roomId = it.room!!.id!!,
+                    title = it.room!!.title,
+                    coverImageUrl = if (it.room!!.coverImage != null) {
+                        "$cloudFrontHost/${it.room!!.coverImage}"
+                    } else {
+                        "$cloudFrontHost/profile/default-profile.png"
+                    },
+                    price = it.room!!.price,
+                    masterNickname = it.room!!.member!!.nickname,
+                    beginDateTime = beginDateTime.format(
+                        DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")
+                    ),
+                    cancelable = beginDateTime.minusHours(4).isAfter(
+                        LocalDateTime.now()
+                            .atZone(ZoneId.of("UTC"))
+                            .withZoneSameInstant(ZoneId.of(timezone))
+                    )
+                )
+            }
+            .toList()
+    }
+
+    fun getReservation(reservationId: Long, memberId: Long, timezone: String): GetLiveReservationResponse {
+        val reservation = repository.getReservationByReservationAndMemberId(reservationId, memberId)
+            ?: throw SodaException("잘못된 예약정보 입니다.")
+
+        val beginDateTime = reservation.room!!.beginDateTime
+            .atZone(ZoneId.of("UTC"))
+            .withZoneSameInstant(ZoneId.of(timezone))
+
+        return GetLiveReservationResponse(
+            reservationId = reservation.id!!,
+            roomId = reservation.room!!.id!!,
+            title = reservation.room!!.title,
+            coverImageUrl = if (reservation.room!!.coverImage != null) {
+                "$cloudFrontHost/${reservation.room!!.coverImage}"
+            } else {
+                "$cloudFrontHost/profile/default-profile.png"
+            },
+            price = reservation.room!!.price,
+            masterNickname = reservation.room!!.member!!.nickname,
+            beginDateTime = beginDateTime.format(
+                DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")
+            ),
+            cancelable = beginDateTime.minusHours(4).isAfter(
+                LocalDateTime.now()
+                    .atZone(ZoneId.of("UTC"))
+                    .withZoneSameInstant(ZoneId.of(timezone))
+            )
+        )
+    }
+
+    @Transactional
+    fun cancelReservation(request: CancelLiveReservationRequest, memberId: Long) {
+        if (request.reason.isBlank()) {
+            throw SodaException("취소사유를 입력하세요.")
+        }
+
+        val reservation = repository.findByIdOrNull(request.reservationId)
+            ?: throw SodaException("잘못된 예약정보 입니다.")
+
+        if (reservation.member == null || reservation.member!!.id!! != memberId) {
+            throw SodaException("잘못된 예약정보 입니다.")
+        }
+
+        if (reservation.room == null || reservation.room?.id == null) {
+            throw SodaException("잘못된 예약정보 입니다.")
+        }
+
+        if (reservation.room!!.beginDateTime.isBefore(LocalDateTime.now().plusHours(4))) {
+            throw SodaException("라이브 시작 4시간 이내에는 예약취소가 불가능 합니다.")
+        }
+
+        if (reservation.room!!.price > 0) {
+            canPaymentService.refund(memberId, roomId = reservation.room!!.id!!)
+        }
+
+        reservation.isActive = false
+
+        val reservationCancel = LiveReservationCancel(request.reason)
+        reservationCancel.reservation = reservation
+        liveReservationCancelRepository.save(reservationCancel)
+    }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt
index 711371b..f060fe9 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt
@@ -381,7 +381,7 @@ class LiveRoomService(
                     it.status = UseCanCalculateStatus.REFUND
 
                     val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
-                    charge.title = "${it.can} 코인"
+                    charge.title = "${it.can} 캔"
                     charge.useCan = useCan
 
                     when (it.paymentGateway) {
@@ -823,20 +823,20 @@ class LiveRoomService(
     @Transactional
     fun refundDonation(roomId: Long, member: Member) {
         val donator = memberRepository.findByIdOrNull(member.id)
-            ?: throw SodaException("후원에 실패한 코인이 환불되지 않았습니다\n고객센터로 문의해주세요.")
+            ?: throw SodaException("후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.")
 
         val useCan = canRepository.getCanUsedForLiveRoomNotRefund(
             memberId = member.id!!,
             roomId = roomId,
             canUsage = CanUsage.DONATION
-        ) ?: throw SodaException("후원에 실패한 코인이 환불되지 않았습니다\n고객센터로 문의해주세요.")
+        ) ?: throw SodaException("후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.")
         useCan.isRefund = true
 
         val useCoinCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
         useCoinCalculates.forEach {
             it.status = UseCanCalculateStatus.REFUND
             val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
-            charge.title = "${it.can} 코인"
+            charge.title = "${it.can} 캔"
             charge.useCan = useCan
 
             when (it.paymentGateway) {