Compare commits

..

2 Commits

29 changed files with 1047 additions and 19 deletions

View File

@ -27,6 +27,7 @@ interface CanQueryRepository {
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long): UseCan?
} }
@Repository @Repository
@ -111,4 +112,19 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
.orderBy(useCan.id.desc()) .orderBy(useCan.id.desc())
.fetchFirst() .fetchFirst()
} }
override fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long): UseCan? {
return queryFactory
.selectFrom(useCan)
.innerJoin(useCan.member, member)
.innerJoin(useCan.room, liveRoom)
.where(
member.id.eq(memberId)
.and(liveRoom.id.eq(roomId))
.and(useCan.canUsage.eq(CanUsage.LIVE))
.and(useCan.isRefund.isFalse)
)
.orderBy(useCan.id.desc())
.fetchFirst()
}
} }

View File

@ -1,7 +1,78 @@
package kr.co.vividnext.sodalive.can.charge package kr.co.vividnext.sodalive.can.charge
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
interface ChargeRepository : JpaRepository<Charge, Long> interface ChargeRepository : JpaRepository<Charge, Long>, ChargeQueryRepository
interface ChargeQueryRepository {
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
}
class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ChargeQueryRepository {
override fun getOldestChargeWhereRewardCanGreaterThan0(
chargeId: Long,
memberId: Long,
container: String
): Charge? {
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, member)
.leftJoin(charge.payment, payment)
.where(
member.id.eq(memberId)
.and(charge.rewardCan.gt(0))
.and(charge.id.gt(chargeId))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(getPaymentGatewayCondition(container))
)
.orderBy(charge.id.asc())
.fetchFirst()
}
override fun getOldestChargeWhereChargeCanGreaterThan0(
chargeId: Long,
memberId: Long,
container: String
): Charge? {
return queryFactory
.selectFrom(charge)
.innerJoin(charge.member, member)
.leftJoin(charge.payment, payment)
.where(
member.id.eq(memberId)
.and(charge.chargeCan.gt(0))
.and(charge.id.gt(chargeId))
.and(payment.status.eq(PaymentStatus.COMPLETE))
.and(getPaymentGatewayCondition(container))
)
.orderBy(charge.id.asc())
.fetchFirst()
}
private fun getPaymentGatewayCondition(container: String): BooleanExpression? {
val paymentGatewayCondition = when (container) {
"aos" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
}
"ios" -> {
payment.paymentGateway.eq(PaymentGateway.PG)
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
}
else -> payment.paymentGateway.eq(PaymentGateway.PG)
}
return paymentGatewayCondition
}
}

View File

@ -0,0 +1,245 @@
package kr.co.vividnext.sodalive.can.payment
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.SpentCan
import kr.co.vividnext.sodalive.can.use.TotalSpentCan
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CanPaymentService(
private val memberRepository: MemberRepository,
private val chargeRepository: ChargeRepository,
private val useCanRepository: UseCanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository
) {
@Transactional
fun spendCan(
memberId: Long,
needCan: Int,
canUsage: CanUsage,
liveRoom: LiveRoom? = null,
container: String
) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val useRewardCan = spendRewardCan(memberId, needCan, container)
val useChargeCan = if (needCan - useRewardCan.total > 0) {
spendChargeCan(memberId, needCan = needCan - useRewardCan.total, container = container)
} else {
null
}
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
throw SodaException(
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
"캔이 부족합니다. 충전 후 이용해 주세요."
)
}
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
}
val useCan = UseCan(
canUsage = canUsage,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total
)
var recipientId: Long? = null
if (canUsage == CanUsage.LIVE && liveRoom != null) {
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else if (canUsage == CanUsage.CHANGE_NICKNAME) {
useCan.member = member
} else if (canUsage == CanUsage.DONATION && liveRoom != null) {
recipientId = liveRoom.member!!.id!!
useCan.room = liveRoom
useCan.member = member
} else {
throw SodaException("잘못된 요청입니다.")
}
useCanRepository.save(useCan)
setUseCoinCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
setUseCoinCalculate(
recipientId,
useRewardCan,
useChargeCan,
useCan,
paymentGateway = PaymentGateway.GOOGLE_IAP
)
setUseCoinCalculate(
recipientId,
useRewardCan,
useChargeCan,
useCan,
paymentGateway = PaymentGateway.APPLE_IAP
)
}
private fun setUseCoinCalculate(
recipientId: Long?,
useRewardCan: TotalSpentCan,
useChargeCan: TotalSpentCan?,
useCan: UseCan,
paymentGateway: PaymentGateway
) {
val totalSpentRewardCan = useRewardCan.spentCans
.filter { it.paymentGateway == paymentGateway }
.fold(0) { sum, spentCans -> sum + spentCans.can }
val useCanCalculate = if (useChargeCan != null) {
val totalSpentChargeCan = useChargeCan.spentCans
.filter { it.paymentGateway == paymentGateway }
.fold(0) { sum, spentCans -> sum + spentCans.can }
UseCanCalculate(
can = totalSpentChargeCan + totalSpentRewardCan,
paymentGateway = paymentGateway,
status = UseCanCalculateStatus.RECEIVED
)
} else {
UseCanCalculate(
can = totalSpentRewardCan,
paymentGateway = paymentGateway,
status = UseCanCalculateStatus.RECEIVED
)
}
if (useCanCalculate.can > 0) {
useCanCalculate.useCan = useCan
useCanCalculate.recipientCreatorId = recipientId
useCanCalculateRepository.save(useCanCalculate)
}
}
private fun spendRewardCan(memberId: Long, needCan: Int, container: String): TotalSpentCan {
return if (needCan > 0) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val spentCans = mutableListOf<SpentCan>()
var chargeId = 0L
var total = 0
while (needCan - total > 0) {
val remainingNeedCan = needCan - total
val charge = chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(chargeId, memberId, container)
?: break
if (charge.rewardCan >= remainingNeedCan) {
charge.rewardCan -= remainingNeedCan
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.pgRewardCan -= remainingNeedCan
}
total += remainingNeedCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = remainingNeedCan
)
)
} else {
total += charge.rewardCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = charge.rewardCan
)
)
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.pgRewardCan -= remainingNeedCan
}
charge.rewardCan = 0
}
chargeId = charge.id!!
}
TotalSpentCan(spentCans, total)
} else {
TotalSpentCan(total = 0)
}
}
private fun spendChargeCan(memberId: Long, needCan: Int, container: String): TotalSpentCan {
return if (needCan > 0) {
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
val spentCans = mutableListOf<SpentCan>()
var chargeId = 0L
var total = 0
while (needCan - total > 0) {
val remainingNeedCan = needCan - total
val charge = chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(chargeId, memberId, container)
?: break
if (charge.rewardCan >= remainingNeedCan) {
charge.rewardCan -= remainingNeedCan
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.pgRewardCan -= remainingNeedCan
}
total += remainingNeedCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = remainingNeedCan
)
)
} else {
total += charge.rewardCan
spentCans.add(
SpentCan(
paymentGateway = charge.payment!!.paymentGateway,
can = charge.rewardCan
)
)
when (charge.payment!!.paymentGateway) {
PaymentGateway.PG -> member.pgRewardCan -= remainingNeedCan
PaymentGateway.APPLE_IAP -> member.appleRewardCan -= remainingNeedCan
PaymentGateway.GOOGLE_IAP -> member.pgRewardCan -= remainingNeedCan
}
charge.rewardCan = 0
}
chargeId = charge.id!!
}
TotalSpentCan(spentCans, total)
} else {
TotalSpentCan(total = 0)
}
}
}

View File

@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.can.use
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
data class TotalSpentCan(
val spentCans: MutableList<SpentCan> = mutableListOf(),
val total: Int
) {
fun verify(): Boolean {
val sumSpentCans = spentCans.fold(0) { sum, spentCan -> sum + spentCan.can }
return total == sumSpentCans
}
}
data class SpentCan(
val paymentGateway: PaymentGateway,
val can: Int
)

View File

@ -30,6 +30,8 @@ data class UseCanCalculate(
value?.useCanCalculates?.add(this) value?.useCanCalculates?.add(this)
field = value field = value
} }
var recipientCreatorId: Long? = null
} }
enum class UseCanCalculateStatus { enum class UseCanCalculateStatus {

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.can.use
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UseCanCalculateRepository : JpaRepository<UseCanCalculate, Long> {
fun findByUseCanIdAndStatus(
useCanId: Long,
status: UseCanCalculateStatus = UseCanCalculateStatus.RECEIVED
): List<UseCanCalculate>
}

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.can.use
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UseCanRepository : JpaRepository<UseCan, Long>

View File

@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.live.reservation
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.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/live/reservation")
class LiveReservationController(private val service: LiveReservationService) {
@PostMapping
fun makeReservation(
@RequestBody request: MakeLiveReservationRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.makeReservation(request, member.id!!))
}
}

View File

@ -0,0 +1,70 @@
package kr.co.vividnext.sodalive.live.reservation
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface LiveReservationRepository : JpaRepository<LiveReservation, Long>, LiveReservationQueryRepository
interface LiveReservationQueryRepository {
fun getReservationList(roomId: Long): List<LiveReservation>
fun cancelReservation(roomId: Long)
fun getReservationBookerList(roomId: Long): List<Member>
fun isExistsReservation(roomId: Long, memberId: Long): Boolean
}
@Repository
class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveReservationQueryRepository {
override fun getReservationList(roomId: Long): List<LiveReservation> {
return queryFactory
.selectFrom(liveReservation)
.innerJoin(liveReservation.room, liveRoom)
.where(
liveRoom.id.eq(roomId)
.and(liveReservation.isActive.isTrue)
)
.fetch()
}
override fun cancelReservation(roomId: Long) {
queryFactory
.update(liveReservation)
.set(liveReservation.isActive, false)
.where(liveReservation.room.id.eq(roomId))
.execute()
}
override fun getReservationBookerList(roomId: Long): List<Member> {
return queryFactory
.select(member)
.from(liveReservation)
.innerJoin(liveReservation.member, member)
.innerJoin(liveReservation.room, liveRoom)
.where(
liveRoom.id.eq(roomId)
.and(liveReservation.isActive.isTrue)
)
.fetch()
}
override fun isExistsReservation(roomId: Long, memberId: Long): Boolean {
return queryFactory
.selectFrom(liveReservation)
.innerJoin(liveReservation.member, member)
.innerJoin(liveReservation.room, liveRoom)
.where(
liveReservation.isActive.isTrue
.and(liveReservation.room.id.eq(roomId))
.and(liveReservation.member.id.eq(memberId))
)
.fetchFirst() != null
}
}

View File

@ -0,0 +1,79 @@
package kr.co.vividnext.sodalive.live.reservation
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
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.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class LiveReservationService(
private val repository: LiveReservationRepository,
private val liveRoomRepository: LiveRoomRepository,
private val memberRepository: MemberRepository,
private val canPaymentService: CanPaymentService
) {
fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
val room = liveRoomRepository.findByIdOrNull(id = request.roomId)
?: throw SodaException(message = "잘못된 요청입니다.\n다시 시도해 주세요.")
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException(message = "로그인 정보를 확인해주세요.")
if (
room.member!!.id!! != memberId &&
room.type == LiveRoomType.PRIVATE &&
(request.password == null || request.password != room.password)
) {
throw SodaException("비밀번호가 일치하지 않습니다.\n다시 확인 후 입력해주세요.")
}
if (repository.isExistsReservation(roomId = request.roomId, memberId = memberId)) {
throw SodaException("이미 예약한 라이브 입니다.")
}
val haveCan = member.getChargeCan(request.container) + member.getRewardCan(request.container)
if (haveCan < room.price) {
throw SodaException("${room.price - haveCan}캔이 부족합니다. 충전 후 이용해 주세요.")
}
if (room.price > 0) {
canPaymentService.spendCan(
memberId = member.id!!,
needCan = room.price,
canUsage = CanUsage.LIVE,
liveRoom = room,
container = request.container
)
}
val reservation = LiveReservation()
reservation.room = room
reservation.member = member
repository.save(reservation)
val beginDateTime = room.beginDateTime
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(request.timezone))
return MakeLiveReservationResponse(
reservationId = reservation.id!!,
nickname = room.member!!.nickname,
title = room.title,
beginDateString = beginDateTime.format(DateTimeFormatter.ofPattern("yyyy년 M월 d일 (E), a hh:mm")),
price = if (room.price > 0) {
"${room.price}"
} else {
"무료"
},
haveCan = haveCan,
useCan = room.price,
remainingCan = haveCan - room.price
)
}
}

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.live.reservation
data class MakeLiveReservationRequest(
val roomId: Long,
val container: String,
val timezone: String = "Asia/Seoul",
val password: String? = null
)

View File

@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.live.reservation
data class MakeLiveReservationResponse(
val reservationId: Long,
val nickname: String,
val title: String,
val beginDateString: String,
val price: String,
val haveCan: Int,
val useCan: Int,
val remainingCan: Int
)

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.live.room
data class EditLiveRoomInfoRequest(
val title: String?,
val notice: String?,
val numberOfPeople: Int?,
val beginDateTimeString: String?,
val timezone: String?
)

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.live.room
data class EnterOrQuitLiveRoomRequest(
val roomId: Long,
val container: String,
val password: String? = null
)

View File

@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.live.room
data class GetRecentRoomInfoResponse(
val title: String,
val notice: String,
var coverImageUrl: String,
val coverImagePath: String,
val numberOfPeople: Int
)

View File

@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.live.room
import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.live.reservation.LiveReservation import kr.co.vividnext.sodalive.live.reservation.LiveReservation
import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancel
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.CascadeType import javax.persistence.CascadeType
@ -44,6 +46,11 @@ data class LiveRoom(
@OneToMany(mappedBy = "room", fetch = FetchType.LAZY) @OneToMany(mappedBy = "room", fetch = FetchType.LAZY)
var reservations: MutableList<LiveReservation> = mutableListOf() var reservations: MutableList<LiveReservation> = mutableListOf()
@OneToMany(mappedBy = "room", fetch = FetchType.LAZY)
var visits: MutableList<LiveRoomVisit> = mutableListOf()
@OneToOne(mappedBy = "room")
var cancel: LiveRoomCancel? = null
var channelName: String? = null var channelName: String? = null
var isActive: Boolean = true var isActive: Boolean = true
} }

View File

@ -2,12 +2,15 @@ package kr.co.vividnext.sodalive.live.room
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping 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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RequestPart
@ -48,10 +51,59 @@ class LiveRoomController(private val service: LiveRoomService) {
@RequestParam timezone: String, @RequestParam timezone: String,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member != null) { if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.getRoomDetail(id, member, timezone))
} else { ApiResponse.ok(service.getRoomDetail(id, member, timezone))
throw SodaException("로그인 정보를 확인해주세요.") }
}
@PostMapping("/enter")
fun enterLive(
@RequestBody request: EnterOrQuitLiveRoomRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.enterLive(request, member))
}
@PutMapping("/start")
fun startLive(
@RequestBody request: StartLiveRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.startLive(request, member))
}
@PutMapping("/cancel")
fun cancelLive(
@RequestBody request: CancelLiveRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.cancelLive(request, member))
}
@GetMapping("/recent-room-info")
fun getRecentRoomInfo(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.getRecentRoomInfo(member))
}
@PutMapping("/{id}")
fun editLiveRoomInfo(
@PathVariable("id") roomId: Long,
@RequestPart("coverImage") coverImage: MultipartFile?,
@RequestPart("request") requestString: String?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
ApiResponse.ok(service.editLiveRoomInfo(roomId, coverImage, requestString, member))
} }
} }

View File

@ -2,11 +2,10 @@ package kr.co.vividnext.sodalive.live.room
import com.querydsl.core.types.OrderSpecifier import com.querydsl.core.types.OrderSpecifier
import com.querydsl.core.types.Predicate import com.querydsl.core.types.Predicate
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.core.types.dsl.Expressions import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.live.reservation.LiveReservation
import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember
@ -33,7 +32,8 @@ interface LiveRoomQueryRepository {
): List<LiveRoom> ): List<LiveRoom>
fun getLiveRoom(id: Long): LiveRoom? fun getLiveRoom(id: Long): LiveRoom?
fun getReservationList(roomId: Long): List<LiveReservation> fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom?
fun getRecentRoomInfo(memberId: Long, cloudFrontHost: String): GetRecentRoomInfoResponse?
} }
class LiveRoomQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomQueryRepository { class LiveRoomQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomQueryRepository {
@ -112,15 +112,35 @@ class LiveRoomQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : L
.fetchFirst() .fetchFirst()
} }
override fun getReservationList(roomId: Long): List<LiveReservation> { override fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? {
return queryFactory return queryFactory
.selectFrom(liveReservation) .selectFrom(liveRoom)
.innerJoin(liveReservation.room, liveRoom) .innerJoin(liveRoom.member, member)
.where( .where(
liveRoom.id.eq(roomId) liveRoom.id.eq(roomId)
.and(liveReservation.isActive.isTrue) .and(liveRoom.isActive.isTrue)
.and(member.id.eq(memberId))
) )
.fetch() .fetchFirst()
}
override fun getRecentRoomInfo(memberId: Long, cloudFrontHost: String): GetRecentRoomInfoResponse? {
return queryFactory
.select(
Projections.constructor(
GetRecentRoomInfoResponse::class.java,
liveRoom.title,
liveRoom.notice,
liveRoom.coverImage.prepend("/").prepend(cloudFrontHost),
liveRoom.coverImage,
liveRoom.numberOfPeople
)
)
.from(liveRoom)
.where(liveRoom.member.id.eq(memberId))
.orderBy(liveRoom.id.desc())
.limit(1)
.fetchFirst()
} }
private fun orderByFieldAccountId( private fun orderByFieldAccountId(

View File

@ -4,29 +4,55 @@ import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.CanRepository 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.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest
import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancel
import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailManager import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailManager
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService
import kr.co.vividnext.sodalive.live.tag.LiveTagRepository import kr.co.vividnext.sodalive.live.tag.LiveTagRepository
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Service @Service
@Transactional(readOnly = true)
class LiveRoomService( class LiveRoomService(
private val repository: LiveRoomRepository, private val repository: LiveRoomRepository,
private val roomInfoRepository: LiveRoomInfoRedisRepository, private val roomInfoRepository: LiveRoomInfoRedisRepository,
private val roomCancelRepository: LiveRoomCancelRepository,
private val useCanCalculateRepository: UseCanCalculateRepository,
private val reservationRepository: LiveReservationRepository,
private val roomVisitService: LiveRoomVisitService,
private val canPaymentService: CanPaymentService,
private val chargeRepository: ChargeRepository,
private val memberRepository: MemberRepository,
private val tagRepository: LiveTagRepository, private val tagRepository: LiveTagRepository,
private val canRepository: CanRepository, private val canRepository: CanRepository,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
@ -90,6 +116,7 @@ class LiveRoomService(
.toList() .toList()
} }
@Transactional
fun createLiveRoom(coverImage: MultipartFile?, requestString: String, member: Member): CreateLiveRoomResponse { fun createLiveRoom(coverImage: MultipartFile?, requestString: String, member: Member): CreateLiveRoomResponse {
val request = objectMapper.readValue(requestString, CreateSudaRoomRequest::class.java) val request = objectMapper.readValue(requestString, CreateSudaRoomRequest::class.java)
if (request.coverImageUrl == null && coverImage == null) { if (request.coverImageUrl == null && coverImage == null) {
@ -191,7 +218,7 @@ class LiveRoomService(
val response = GetRoomDetailResponse( val response = GetRoomDetailResponse(
roomId = roomId, roomId = roomId,
title = room.title, title = room.title,
content = room.notice, notice = room.notice,
price = room.price, price = room.price,
tags = room.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(), tags = room.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(),
numberOfParticipantsTotal = room.numberOfPeople, numberOfParticipantsTotal = room.numberOfPeople,
@ -251,7 +278,7 @@ class LiveRoomService(
response.numberOfParticipants = users.size response.numberOfParticipants = users.size
} }
} else { } else {
val reservationList = repository.getReservationList(roomId) val reservationList = reservationRepository.getReservationList(roomId)
response.participatingUsers = reservationList response.participatingUsers = reservationList
.asSequence() .asSequence()
.map { .map {
@ -267,4 +294,201 @@ class LiveRoomService(
return response return response
} }
@Transactional
fun startLive(request: StartLiveRequest, member: Member) {
val room = repository.getLiveRoomAndAccountId(request.roomId, member.id!!)
?: throw SodaException("해당하는 라이브가 없습니다.")
val dateTime = LocalDateTime.now()
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(request.timezone))
.toLocalDateTime()
val beginDateTime = room.beginDateTime
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(request.timezone))
.toLocalDateTime()
if (dateTime.plusMinutes(10).isBefore(beginDateTime)) {
val startAvailableDateTimeString = beginDateTime.minusMinutes(10).format(
DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")
)
throw SodaException("$startAvailableDateTimeString 이후에 시작할 수 있습니다.")
}
room.channelName = "SODA_LIVE_CHANNEL_" +
"${member.id}_${dateTime.year}_${dateTime.month}_${dateTime.dayOfMonth}_" +
"${dateTime.hour}_${dateTime.minute}"
}
fun cancelLive(request: CancelLiveRequest, member: Member) {
val room = repository.getLiveRoomAndAccountId(request.roomId, member.id!!)
?: throw SodaException("해당하는 라이브가 없습니다.")
if (request.reason.isBlank()) {
throw SodaException("취소사유를 입력해 주세요.")
}
room.isActive = false
val roomCancel = LiveRoomCancel(request.reason)
roomCancel.room = room
roomCancelRepository.save(roomCancel)
// 유료방인 경우 환불처리
if (room.price > 0) {
val bookerList = reservationRepository.getReservationBookerList(roomId = room.id!!)
for (booker in bookerList) {
val useCan = canRepository.getCanUsedForLiveRoomNotRefund(memberId = booker.id!!, roomId = room.id!!)
?: continue
useCan.isRefund = true
val useCanCalculate = useCanCalculateRepository.findByUseCanIdAndStatus(useCanId = useCan.id!!)
useCanCalculate.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 -> booker.pgRewardCan += charge.rewardCan
PaymentGateway.GOOGLE_IAP -> booker.googleRewardCan += charge.rewardCan
PaymentGateway.APPLE_IAP -> booker.appleRewardCan += charge.rewardCan
}
charge.member = booker
val payment = Payment(
status = PaymentStatus.COMPLETE,
paymentGateway = it.paymentGateway
)
payment.method = "환불"
charge.payment = payment
chargeRepository.save(charge)
}
}
}
reservationRepository.cancelReservation(roomId = room.id!!)
}
fun enterLive(request: EnterOrQuitLiveRoomRequest, member: Member) {
val room = repository.getLiveRoom(id = request.roomId)
?: throw SodaException("해당하는 라이브가 없습니다.")
if (
room.member!!.id!! != member.id!! &&
room.type == LiveRoomType.PRIVATE &&
(request.password == null || request.password != room.password)
) {
throw SodaException("비밀번호가 일치하지 않습니다.\n다시 확인 후 입력해주세요.")
}
var roomInfo = roomInfoRepository.findByIdOrNull(request.roomId)
if (roomInfo == null) {
roomInfo = roomInfoRepository.save(LiveRoomInfo(roomId = request.roomId))
}
if (roomInfo.speakerCount + roomInfo.listenerCount + roomInfo.managerCount >= room.numberOfPeople) {
throw SodaException("방이 가득찼습니다.")
}
if (
room.price > 0 &&
room.member!!.id!! != member.id!! &&
canRepository.isExistPaidLiveRoom(memberId = member.id!!, roomId = request.roomId) == null
) {
val findMember = memberRepository.findByIdOrNull(id = member.id!!)
?: throw SodaException("로그인 정보를 확인해 주세요.")
val totalCan = findMember.getChargeCan(request.container) + findMember.getRewardCan(request.container)
if (totalCan < room.price) {
throw SodaException("${room.price - totalCan}캔이 부족합니다. 충전 후 이용해 주세요.")
}
canPaymentService.spendCan(
memberId = member.id!!,
needCan = room.price,
canUsage = CanUsage.LIVE,
liveRoom = room,
container = request.container
)
}
roomInfo.removeListener(member)
roomInfo.removeSpeaker(member)
roomInfo.removeManager(member)
if (room.member!!.id == member.id) {
roomInfo.addSpeaker(member)
} else {
roomInfo.addListener(member)
}
roomInfoRepository.save(roomInfo)
roomVisitService.roomVisit(room, member)
}
fun getRecentRoomInfo(member: Member): GetRecentRoomInfoResponse {
return repository.getRecentRoomInfo(memberId = member.id!!, cloudFrontHost = cloudFrontHost)
?: throw SodaException("최근 데이터가 없습니다.")
}
@Transactional
fun editLiveRoomInfo(roomId: Long, coverImage: MultipartFile?, requestString: String?, member: Member) {
val room = repository.getLiveRoom(roomId)
if (member.id == null || room?.member?.id != member.id!!) {
throw SodaException("잘못된 요청입니다.")
}
if (coverImage == null && requestString == null) {
throw SodaException("변경사항이 없습니다.")
}
if (requestString != null) {
val request = objectMapper.readValue(requestString, EditLiveRoomInfoRequest::class.java)
if (request.title != null) {
room.title = request.title
}
if (request.notice != null) {
room.notice = request.notice
}
if (request.numberOfPeople != null) {
room.numberOfPeople = request.numberOfPeople
}
if (request.beginDateTimeString != null && request.timezone != null) {
room.beginDateTime = request.beginDateTimeString.convertLocalDateTime("yyyy-MM-dd HH:mm")
.atZone(ZoneId.of(request.timezone))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
}
}
if (coverImage != null) {
val metadata = ObjectMetadata()
metadata.contentLength = coverImage.size
// 커버 이미지 파일명 생성
val coverImageFileName = generateFileName(prefix = "${room.id}-cover")
// 커버 이미지 업로드
val coverImagePath = s3Uploader.upload(
inputStream = coverImage.inputStream,
bucket = coverImageBucket,
filePath = "suda_room_cover/${room.id}/$coverImageFileName",
metadata = metadata
)
room.bgImage = coverImagePath
if (room.channelName == null) {
room.coverImage = coverImagePath
}
}
}
} }

View File

@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.live.room
import java.util.TimeZone
data class StartLiveRequest(
val roomId: Long,
val timezone: String = TimeZone.getDefault().id
)

View File

@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.live.room.cancel
data class CancelLiveRequest(val roomId: Long, val reason: String)

View File

@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.live.room.cancel
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.live.room.LiveRoom
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.OneToOne
@Entity
data class LiveRoomCancel(
val reason: String
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "room_id", nullable = false)
var room: LiveRoom? = null
set(value) {
value?.cancel = this
field = value
}
}

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.live.room.cancel
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface LiveRoomCancelRepository : JpaRepository<LiveRoomCancel, Long>

View File

@ -7,7 +7,7 @@ data class GetRoomDetailResponse(
val roomId: Long, val roomId: Long,
val price: Int, val price: Int,
val title: String, val title: String,
val content: String, val notice: String,
var isPaid: Boolean, var isPaid: Boolean,
val isPrivateRoom: Boolean, val isPrivateRoom: Boolean,
val password: String?, val password: String?,

View File

@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.live.room.visit
data class GetRecentVisitRoomMemberResponse(
val userId: Long,
val nickname: String,
val profileImageUrl: String
)

View File

@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.live.room.visit
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class LiveRoomVisit : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "room_id", nullable = false)
var room: LiveRoom? = null
set(value) {
value?.visits?.add(this)
field = value
}
}

View File

@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.live.room.visit
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface LiveRoomVisitRepository : JpaRepository<LiveRoomVisit, Long>, LiveRoomVisitQueryRepository
interface LiveRoomVisitQueryRepository {
fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit?
fun findFirstByMemberIdOrderByUpdatedAtDesc(memberId: Long): LiveRoomVisit?
}
@Repository
class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomVisitQueryRepository {
override fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit? {
return queryFactory
.selectFrom(liveRoomVisit)
.innerJoin(liveRoomVisit.room, liveRoom)
.innerJoin(liveRoomVisit.member, member)
.where(
liveRoom.id.eq(roomId)
.and(member.id.eq(memberId))
)
.orderBy(liveRoomVisit.id.desc())
.fetchFirst()
}
override fun findFirstByMemberIdOrderByUpdatedAtDesc(memberId: Long): LiveRoomVisit? {
return queryFactory
.selectFrom(liveRoomVisit)
.innerJoin(liveRoomVisit.room, liveRoom)
.where(member.id.eq(memberId))
.orderBy(liveRoomVisit.updatedAt.desc())
.fetchFirst()
}
}

View File

@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.live.room.visit
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class LiveRoomVisitService(private val repository: LiveRoomVisitRepository) {
@Transactional
fun roomVisit(room: LiveRoom, member: Member) {
var roomVisit = repository.findByRoomIdAndMemberId(room.id!!, member.id!!)
if (roomVisit == null) {
roomVisit = LiveRoomVisit()
roomVisit.member = member
roomVisit.room = room
} else {
roomVisit.updatedAt = LocalDateTime.now()
}
repository.save(roomVisit)
}
}

View File

@ -53,9 +53,9 @@ data class Member(
// 화폐 // 화폐
private var pgChargeCan: Int = 0 private var pgChargeCan: Int = 0
private var pgRewardCan: Int = 0 var pgRewardCan: Int = 0
private var googleChargeCan: Int = 0 private var googleChargeCan: Int = 0
private var googleRewardCan: Int = 0 var googleRewardCan: Int = 0
var appleChargeCan: Int = 0 var appleChargeCan: Int = 0
var appleRewardCan: Int = 0 var appleRewardCan: Int = 0