diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index 1c0dcea6..97ffa77d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.can import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.audition.QAudition.audition +import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant +import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole import kr.co.vividnext.sodalive.can.QCan.can1 import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.ChargeStatus @@ -10,7 +13,12 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.can.payment.QPayment.payment import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter +import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.QMember @@ -24,7 +32,7 @@ interface CanRepository : JpaRepository, CanQueryRepository interface CanQueryRepository { fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List - fun getCanUseStatus(member: Member, pageable: Pageable): List + fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? @@ -58,13 +66,68 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .fetch() } - override fun getCanUseStatus(member: Member, pageable: Pageable): List { + override fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List { + val qRoomMember = QMember("roomMember") + val qAudioContentMember = QMember("audioContentMember") + val qCommunityPostMember = QMember("communityPostMember") + val qRecipientMember = QMember("recipientMember") + + val gatewayCondition = when (container) { + "aos" -> useCanCalculate.paymentGateway.`in`( + PaymentGateway.PG, + PaymentGateway.PAYVERSE, + PaymentGateway.GOOGLE_IAP + ) + + "ios" -> useCanCalculate.paymentGateway.`in`( + PaymentGateway.PG, + PaymentGateway.PAYVERSE, + PaymentGateway.APPLE_IAP + ) + + else -> useCanCalculate.paymentGateway.`in`(PaymentGateway.PG, PaymentGateway.PAYVERSE) + } + return queryFactory - .selectFrom(useCan) - .where(useCan.member.id.eq(member.id)) + .select( + QUseCanQueryDto( + useCan.canUsage, + useCan.can, + useCan.rewardCan, + useCan.createdAt, + qRoomMember.nickname, + liveRoom.title, + qAudioContentMember.nickname, + audioContent.title, + qCommunityPostMember.nickname, + audition.title, + chatCharacter.name, + qRecipientMember.nickname + ) + ) + .from(useCan) + .leftJoin(useCan.room, liveRoom) + .leftJoin(liveRoom.member, qRoomMember) + .leftJoin(useCan.audioContent, audioContent) + .leftJoin(audioContent.member, qAudioContentMember) + .leftJoin(useCan.communityPost, creatorCommunity) + .leftJoin(creatorCommunity.member, qCommunityPostMember) + .leftJoin(useCan.auditionApplicant, auditionApplicant) + .leftJoin(auditionApplicant.role, auditionRole) + .leftJoin(auditionRole.audition, audition) + .leftJoin(useCan.characterImage, characterImage) + .leftJoin(characterImage.chatCharacter, chatCharacter) + .innerJoin(useCan.useCanCalculates, useCanCalculate) + .leftJoin(qRecipientMember).on(useCanCalculate.recipientCreatorId.eq(qRecipientMember.id)) + .where( + useCan.member.id.eq(member.id) + .and(useCan.can.add(useCan.rewardCan).gt(0)) + .and(gatewayCondition) + ) .offset(pageable.offset) .limit(pageable.pageSize.toLong()) .orderBy(useCan.id.desc()) + .distinct() .fetch() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index b0b64759..1e99c89b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -1,11 +1,9 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.can.charge.ChargeStatus -import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.MemberRepository import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.time.ZoneId @@ -14,8 +12,7 @@ import java.time.format.DateTimeFormatter @Service class CanService( private val repository: CanRepository, - private val countryContext: CountryContext, - private val memberRepository: MemberRepository + private val countryContext: CountryContext ) { fun getCans(isNotSelectedCurrency: Boolean): List { val currency = if (isNotSelectedCurrency) { @@ -42,88 +39,94 @@ class CanService( timezone: String, container: String ): List { - val useCanList = repository.getCanUseStatus(member, pageable) - .filter { (it.can + it.rewardCan) > 0 } - .filter { - when (container) { - "aos" -> { - it.useCanCalculates.any { useCanCalculate -> - useCanCalculate.paymentGateway == PaymentGateway.PG || - useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE || - useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP - } - } - - "ios" -> { - it.useCanCalculates.any { useCanCalculate -> - useCanCalculate.paymentGateway == PaymentGateway.PG || - useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE || - useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP - } - } - - else -> it.useCanCalculates.any { useCanCalculate -> - useCanCalculate.paymentGateway == PaymentGateway.PG || - useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE - } - } - } - - val channelDonationCreatorIds = useCanList - .asSequence() - .filter { it.canUsage == CanUsage.CHANNEL_DONATION } - .mapNotNull { it.useCanCalculates.firstOrNull()?.recipientCreatorId } - .distinct() - .toList() - - val channelDonationCreatorNicknameMap = if (channelDonationCreatorIds.isEmpty()) { - emptyMap() - } else { - memberRepository.findAllById(channelDonationCreatorIds).associate { it.id!! to it.nickname } + val zoneId = try { + ZoneId.of(timezone) + } catch (_: Exception) { + ZoneId.of("UTC") } - return useCanList + return repository.getCanUseStatus(member, pageable, container) .map { val title: String = when (it.canUsage) { CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> { - if (it.room != null) { - "[라이브 후원] ${it.room!!.member!!.nickname}" - } else if (it.audioContent != null) { - "[콘텐츠 후원] ${it.audioContent!!.member!!.nickname}" + if (it.roomMemberNickname != null) { + "[라이브 후원] ${it.roomMemberNickname}" + } else if (it.audioContentMemberNickname != null) { + "[콘텐츠 후원] ${it.audioContentMemberNickname}" } else { "[후원]" } } CanUsage.CHANNEL_DONATION -> { - val creatorId = it.useCanCalculates.firstOrNull()?.recipientCreatorId - val creatorNickname = creatorId?.let { id -> channelDonationCreatorNicknameMap[id] } - - if (creatorNickname.isNullOrBlank()) { + if (it.recipientCreatorNickname.isNullOrBlank()) { "[채널 후원]" } else { - "[채널 후원] $creatorNickname" + "[채널 후원] ${it.recipientCreatorNickname}" } } CanUsage.LIVE -> { - "[라이브] ${it.room!!.title}" + if (it.roomTitle != null) { + "[라이브] ${it.roomTitle}" + } else if (it.roomMemberNickname != null) { + "[라이브] ${it.roomMemberNickname}" + } else { + "[라이브]" + } } CanUsage.CHANGE_NICKNAME -> "닉네임 변경" CanUsage.ALARM_SLOT -> "알람 슬롯 구매" - CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" - CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" - CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" - CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" - CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" + CanUsage.ORDER_CONTENT -> { + if (it.audioContentTitle != null) { + "[콘텐츠 구매] ${it.audioContentTitle}" + } else if (it.audioContentMemberNickname != null) { + "[콘텐츠 구매] ${it.audioContentMemberNickname}" + } else { + "[콘텐츠 구매]" + } + } + + CanUsage.PAID_COMMUNITY_POST -> { + if (it.communityPostMemberNickname != null) { + "[게시글 보기] ${it.communityPostMemberNickname}" + } else { + "[게시글 보기]" + } + } + + CanUsage.AUDITION_VOTE -> { + if (it.auditionTitle != null) { + "[오디션 투표] ${it.auditionTitle}" + } else { + "[오디션 투표]" + } + } + + CanUsage.CHAT_MESSAGE_PURCHASE -> { + if (it.characterName != null) { + "[메시지 구매] ${it.characterName}" + } else { + "[메시지 구매]" + } + } + + CanUsage.CHARACTER_IMAGE_PURCHASE -> { + if (it.characterName != null) { + "[캐릭터 이미지 구매] ${it.characterName}" + } else { + "[캐릭터 이미지 구매]" + } + } + CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화" } - val createdAt = it.createdAt!! + val createdAt = it.createdAt .atZone(ZoneId.of("UTC")) - .withZoneSameInstant(ZoneId.of(timezone)) + .withZoneSameInstant(zoneId) GetCanUseStatusResponseItem( title = title, @@ -141,6 +144,12 @@ class CanService( timezone: String, container: String ): List { + val zoneId = try { + ZoneId.of(timezone) + } catch (e: Exception) { + ZoneId.of("UTC") + } + return repository.getCanChargeStatus(member, pageable, container) .map { val canTitle = it.title ?: "" @@ -170,9 +179,9 @@ class CanService( } } - val createdAt = it.createdAt!! + val createdAt = (it.createdAt ?: it.updatedAt!!) .atZone(ZoneId.of("UTC")) - .withZoneSameInstant(ZoneId.of(timezone)) + .withZoneSameInstant(zoneId) GetCanChargeStatusResponseItem( canTitle = canTitle, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt new file mode 100644 index 00000000..c11bab22 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/UseCanQueryDto.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.can + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.can.use.CanUsage +import java.time.LocalDateTime + +data class UseCanQueryDto @QueryProjection constructor( + val canUsage: CanUsage, + val can: Int, + val rewardCan: Int, + val createdAt: LocalDateTime, + val roomMemberNickname: String?, + val roomTitle: String?, + val audioContentMemberNickname: String?, + val audioContentTitle: String?, + val communityPostMemberNickname: String?, + val auditionTitle: String?, + val characterName: String?, + val recipientCreatorNickname: String? +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt new file mode 100644 index 00000000..977294a6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt @@ -0,0 +1,165 @@ +package kr.co.vividnext.sodalive.can + +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.member.Member +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime + +class CanServiceTest { + private lateinit var repository: CanRepository + private lateinit var countryContext: CountryContext + private lateinit var service: CanService + + @BeforeEach + fun setUp() { + repository = mock(CanRepository::class.java) + countryContext = mock(CountryContext::class.java) + service = CanService(repository, countryContext) + } + + @Test + @DisplayName("AOS 컨테이너에 대해 캔 사용 내역이 올바르게 필터링 및 맵핑되는지 확인한다") + fun `should filter can use status correctly for aos`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + val useCanDto1 = createUseCanDto(CanUsage.HEART, 10, 0, "nick1", null) + val useCanDto2 = createUseCanDto(CanUsage.DONATION, 0, 5, "nick2", null) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(listOf(useCanDto1, useCanDto2)) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos") + + // then + assertEquals(2, result.size) + assertEquals("[라이브 후원] nick1", result[0].title) + assertEquals(10, result[0].can) + assertEquals("[라이브 후원] nick2", result[1].title) + assertEquals(5, result[1].can) + } + + @Test + @DisplayName("iOS 컨테이너에 대해 캔 사용 내역이 올바르게 필터링 및 맵핑되는지 확인한다") + fun `should filter can use status correctly for ios`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + val useCanDto1 = createUseCanDto(CanUsage.HEART, 10, 0, "nick1", null) + val useCanDto2 = createUseCanDto(CanUsage.DONATION, 10, 0, "nick2", null) + + `when`(repository.getCanUseStatus(member, pageable, "ios")).thenReturn(listOf(useCanDto1, useCanDto2)) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "ios") + + // then + assertEquals(2, result.size) + } + + @Test + @DisplayName("조회 결과가 없을 때 빈 리스트를 반환하는지 확인한다") + fun `should return empty list when no status exists`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(emptyList()) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos") + + // then + assertEquals(0, result.size) + } + + @Test + @DisplayName("유효하지 않은 타임존 입력 시 UTC를 기본으로 사용한다") + fun `should use UTC when timezone is invalid`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + val useCanDto = createUseCanDto(CanUsage.HEART, 10, 0) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(listOf(useCanDto)) + + // when + val result = service.getCanUseStatus(member, pageable, "Invalid/Timezone", "aos") + + // then + assertEquals(1, result.size) + } + + @Test + @DisplayName("다양한 CanUsage 및 null 필드에 대해 타이틀이 올바르게 포맷팅되는지 확인한다") + fun `should handle various can usage and nullable fields correctly`() { + // given + val member = Member(nickname = "user1", password = "password") + member.id = 1L + val pageable = PageRequest.of(0, 10) + + val dtos = listOf( + createUseCanDto(CanUsage.HEART, 10, 0, roomMemberNickname = null, audioContentMemberNickname = null), + createUseCanDto(CanUsage.CHANNEL_DONATION, 10, 0, recipientCreatorNickname = null), + createUseCanDto(CanUsage.LIVE, 10, 0, roomMemberNickname = "creator1"), + createUseCanDto(CanUsage.LIVE, 10, 0), + createUseCanDto(CanUsage.CHANGE_NICKNAME, 10, 0), + createUseCanDto(CanUsage.CHAT_QUOTA_PURCHASE, 10, 0) + ) + + `when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(dtos) + + // when + val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos") + + // then + assertEquals("[후원]", result[0].title) + assertEquals("[채널 후원]", result[1].title) + assertEquals("[라이브] creator1", result[2].title) + assertEquals("[라이브]", result[3].title) + assertEquals("닉네임 변경", result[4].title) + assertEquals("캐릭터 톡 이용권 구매", result[5].title) + } + + private fun createUseCanDto( + usage: CanUsage, + can: Int, + rewardCan: Int, + roomMemberNickname: String? = null, + recipientCreatorNickname: String? = null, + roomTitle: String? = null, + audioContentMemberNickname: String? = null, + audioContentTitle: String? = null, + communityPostMemberNickname: String? = null, + auditionTitle: String? = null, + characterName: String? = null + ): UseCanQueryDto { + return UseCanQueryDto( + canUsage = usage, + can = can, + rewardCan = rewardCan, + createdAt = LocalDateTime.now(), + roomMemberNickname = roomMemberNickname, + roomTitle = roomTitle, + audioContentMemberNickname = audioContentMemberNickname, + audioContentTitle = audioContentTitle, + communityPostMemberNickname = communityPostMemberNickname, + auditionTitle = auditionTitle, + characterName = characterName, + recipientCreatorNickname = recipientCreatorNickname + ) + } +}