diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt index e886de05..2d01efcc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt @@ -27,7 +27,9 @@ class ChatRoomQuotaController( ) { data class PurchaseRoomQuotaRequest( - val container: String + val container: String, + val chargeType: ChatRoomQuotaChargeType = ChatRoomQuotaChargeType.CAN, + val canOption: ChatRoomQuotaCanOption? = null ) data class PurchaseRoomQuotaResponse( @@ -45,8 +47,9 @@ class ChatRoomQuotaController( /** * 채팅방 유료 쿼터 구매 API * - 참여 여부 검증(내가 USER로 참여 중인 활성 방) - * - 30캔 결제 (UseCan에 chatRoomId:characterId 기록) - * - 방 유료 쿼터 40 충전 + * - 요청 DTO로 캔 충전 / 광고 충전을 구분 + * - 캔 충전은 옵션별 캔 차감 후 방 유료 쿼터 지급 + * - 광고 충전은 캔 차감 없이 방 유료 쿼터 5 지급 */ @PostMapping("/{chatRoomId}/quota/purchase") fun purchaseRoomQuota( @@ -74,13 +77,28 @@ class ChatRoomQuotaController( val characterId = character.id ?: throw SodaException(messageKey = "chat.room.quota.character_required") - val status = chatRoomQuotaService.purchase( - memberId = member.id!!, - chatRoomId = chatRoomId, - characterId = characterId, - addPaid = 12, - container = req.container - ) + val chargeType = req.chargeType + val status = when (chargeType) { + ChatRoomQuotaChargeType.CAN -> { + val canOption = req.canOption ?: ChatRoomQuotaCanOption.CAN_10 + + chatRoomQuotaService.purchaseWithCan( + memberId = member.id!!, + chatRoomId = chatRoomId, + characterId = characterId, + canOption = canOption, + container = req.container + ) + } + + ChatRoomQuotaChargeType.AD -> { + chatRoomQuotaService.purchaseWithAd( + memberId = member.id!!, + chatRoomId = chatRoomId, + characterId = characterId + ) + } + } ApiResponse.ok( PurchaseRoomQuotaResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaPurchaseOption.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaPurchaseOption.kt new file mode 100644 index 00000000..91984ef5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaPurchaseOption.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.chat.quota.room + +enum class ChatRoomQuotaChargeType { + CAN, + AD +} + +enum class ChatRoomQuotaCanOption( + val needCan: Int, + val quota: Int +) { + CAN_10(10, 15), + CAN_20(20, 40) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt index b6ba430d..a1a5006d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt @@ -13,6 +13,10 @@ class ChatRoomQuotaService( private val repo: ChatRoomQuotaRepository, private val canPaymentService: CanPaymentService ) { + companion object { + private const val AD_REWARD_QUOTA = 5 + } + data class RoomQuotaStatus( val totalRemaining: Int, val nextRechargeAtEpochMillis: Long?, @@ -122,23 +126,50 @@ class ChatRoomQuotaService( } @Transactional - fun purchase( + fun purchaseWithCan( memberId: Long, chatRoomId: Long, characterId: Long, - addPaid: Int = 12, + canOption: ChatRoomQuotaCanOption, container: String ): RoomQuotaStatus { - // 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록 canPaymentService.spendCan( memberId = memberId, - needCan = 10, + needCan = canOption.needCan, canUsage = CanUsage.CHAT_QUOTA_PURCHASE, chatRoomId = chatRoomId, characterId = characterId, container = container ) + return addPaidQuota( + memberId = memberId, + chatRoomId = chatRoomId, + characterId = characterId, + addPaid = canOption.quota + ) + } + + @Transactional + fun purchaseWithAd( + memberId: Long, + chatRoomId: Long, + characterId: Long + ): RoomQuotaStatus { + return addPaidQuota( + memberId = memberId, + chatRoomId = chatRoomId, + characterId = characterId, + addPaid = AD_REWARD_QUOTA + ) + } + + private fun addPaidQuota( + memberId: Long, + chatRoomId: Long, + characterId: Long, + addPaid: Int + ): RoomQuotaStatus { val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save( ChatRoomQuota( memberId = memberId, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt new file mode 100644 index 00000000..fe2fec83 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt @@ -0,0 +1,240 @@ +package kr.co.vividnext.sodalive.chat.quota.room + +import kr.co.vividnext.sodalive.chat.character.CharacterType +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService +import kr.co.vividnext.sodalive.chat.room.ChatParticipant +import kr.co.vividnext.sodalive.chat.room.ChatRoom +import kr.co.vividnext.sodalive.chat.room.ParticipantType +import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository +import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +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 + +class ChatRoomQuotaControllerTest { + private lateinit var chatRoomRepository: ChatRoomRepository + private lateinit var participantRepository: ChatParticipantRepository + private lateinit var chatRoomQuotaService: ChatRoomQuotaService + private lateinit var chatQuotaService: ChatQuotaService + private lateinit var memberContentPreferenceService: MemberContentPreferenceService + private lateinit var controller: ChatRoomQuotaController + + @BeforeEach + fun setup() { + chatRoomRepository = Mockito.mock(ChatRoomRepository::class.java) + participantRepository = Mockito.mock(ChatParticipantRepository::class.java) + chatRoomQuotaService = Mockito.mock(ChatRoomQuotaService::class.java) + chatQuotaService = Mockito.mock(ChatQuotaService::class.java) + memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + controller = ChatRoomQuotaController( + chatRoomRepository = chatRoomRepository, + participantRepository = participantRepository, + chatRoomQuotaService = chatRoomQuotaService, + chatQuotaService = chatQuotaService, + memberContentPreferenceService = memberContentPreferenceService + ) + } + + @Test + @DisplayName("캔 충전 요청은 선택한 캔 옵션으로 서비스에 전달된다") + fun shouldDelegateCanPurchaseWithSelectedCanOption() { + val member = createMember(id = 7L, nickname = "user") + val room = createRoom(id = 101L) + val character = createCharacter(id = 202L) + stubAccessibleRoom(member, room, character) + Mockito.`when`( + chatRoomQuotaService.purchaseWithCan( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!!, + canOption = ChatRoomQuotaCanOption.CAN_20, + container = "aos" + ) + ).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(50, null, 10, 40)) + + val response = controller.purchaseRoomQuota( + member = member, + chatRoomId = room.id!!, + req = ChatRoomQuotaController.PurchaseRoomQuotaRequest( + container = "aos", + chargeType = ChatRoomQuotaChargeType.CAN, + canOption = ChatRoomQuotaCanOption.CAN_20 + ) + ) + + assertEquals(true, response.success) + assertEquals(50, response.data!!.totalRemaining) + assertEquals(40, response.data!!.remainingPaid) + Mockito.verify(chatRoomQuotaService).purchaseWithCan( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!!, + canOption = ChatRoomQuotaCanOption.CAN_20, + container = "aos" + ) + } + + @Test + @DisplayName("container만 있는 기존 요청은 기본적으로 10캔 충전으로 처리한다") + fun shouldFallbackToCan10WhenOnlyContainerIsProvided() { + val member = createMember(id = 17L, nickname = "legacy") + val room = createRoom(id = 301L) + val character = createCharacter(id = 402L) + stubAccessibleRoom(member, room, character) + Mockito.`when`( + chatRoomQuotaService.purchaseWithCan( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!!, + canOption = ChatRoomQuotaCanOption.CAN_10, + container = "aos" + ) + ).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(25, null, 10, 15)) + + val response = controller.purchaseRoomQuota( + member = member, + chatRoomId = room.id!!, + req = ChatRoomQuotaController.PurchaseRoomQuotaRequest( + container = "aos" + ) + ) + + assertEquals(true, response.success) + assertEquals(25, response.data!!.totalRemaining) + assertEquals(15, response.data!!.remainingPaid) + Mockito.verify(chatRoomQuotaService).purchaseWithCan( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!!, + canOption = ChatRoomQuotaCanOption.CAN_10, + container = "aos" + ) + } + + @Test + @DisplayName("광고 충전 요청은 캔 차감 없이 광고 서비스 경로로 전달된다") + fun shouldDelegateAdPurchaseWithoutCanOption() { + val member = createMember(id = 8L, nickname = "user") + val room = createRoom(id = 111L) + val character = createCharacter(id = 222L) + stubAccessibleRoom(member, room, character) + Mockito.`when`( + chatRoomQuotaService.purchaseWithAd( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!! + ) + ).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5)) + + val response = controller.purchaseRoomQuota( + member = member, + chatRoomId = room.id!!, + req = ChatRoomQuotaController.PurchaseRoomQuotaRequest( + container = "aos", + chargeType = ChatRoomQuotaChargeType.AD, + canOption = null + ) + ) + + assertEquals(true, response.success) + assertEquals(15, response.data!!.totalRemaining) + assertEquals(5, response.data!!.remainingPaid) + Mockito.verify(chatRoomQuotaService).purchaseWithAd( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!! + ) + } + + @Test + @DisplayName("광고 충전 요청에 캔 옵션이 포함되어도 무시하고 광고 충전을 처리한다") + fun shouldIgnoreCanOptionWhenAdPurchaseContainsIt() { + val member = createMember(id = 9L, nickname = "user") + val room = createRoom(id = 121L) + val character = createCharacter(id = 232L) + stubAccessibleRoom(member, room, character) + Mockito.`when`( + chatRoomQuotaService.purchaseWithAd( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!! + ) + ).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5)) + + val response = controller.purchaseRoomQuota( + member = member, + chatRoomId = room.id!!, + req = ChatRoomQuotaController.PurchaseRoomQuotaRequest( + container = "aos", + chargeType = ChatRoomQuotaChargeType.AD, + canOption = ChatRoomQuotaCanOption.CAN_10 + ) + ) + + assertEquals(true, response.success) + assertEquals(15, response.data!!.totalRemaining) + assertEquals(5, response.data!!.remainingPaid) + Mockito.verify(chatRoomQuotaService).purchaseWithAd( + memberId = member.id!!, + chatRoomId = room.id!!, + characterId = character.id!! + ) + } + + private fun stubAccessibleRoom(member: Member, room: ChatRoom, character: ChatCharacter) { + Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn( + ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = true, + contentType = ContentType.ALL, + isAdult = true + ) + ) + Mockito.`when`(chatRoomRepository.findByIdAndIsActiveTrue(room.id!!)).thenReturn(room) + Mockito.`when`(participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)).thenReturn( + ChatParticipant(chatRoom = room, participantType = ParticipantType.USER, member = member) + ) + Mockito.`when`( + participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) + ).thenReturn( + ChatParticipant(chatRoom = room, participantType = ParticipantType.CHARACTER, character = character) + ) + } + + private fun createMember(id: Long, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = MemberRole.USER + ) + member.id = id + return member + } + + private fun createRoom(id: Long): ChatRoom { + val room = ChatRoom(sessionId = "session-$id", title = "room-$id") + room.id = id + return room + } + + private fun createCharacter(id: Long): ChatCharacter { + val character = ChatCharacter( + characterUUID = "character-$id", + name = "character-$id", + description = "desc", + systemPrompt = "prompt", + characterType = CharacterType.Character + ) + character.id = id + return character + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt new file mode 100644 index 00000000..d3cd60a9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt @@ -0,0 +1,105 @@ +package kr.co.vividnext.sodalive.chat.quota.room + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +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 + +class ChatRoomQuotaServiceTest { + private lateinit var repo: ChatRoomQuotaRepository + private lateinit var canPaymentService: CanPaymentService + private lateinit var service: ChatRoomQuotaService + + @BeforeEach + fun setup() { + repo = Mockito.mock(ChatRoomQuotaRepository::class.java) + canPaymentService = Mockito.mock(CanPaymentService::class.java) + service = ChatRoomQuotaService(repo, canPaymentService) + } + + @Test + @DisplayName("10캔 충전은 15개 유료 quota를 지급한다") + fun shouldAddFifteenPaidQuotaWhenPurchasingCan10Option() { + val quota = ChatRoomQuota(memberId = 1L, chatRoomId = 2L, characterId = 3L, remainingFree = 10, remainingPaid = 0) + Mockito.`when`(repo.findForUpdate(1L, 2L)).thenReturn(quota) + + val result = service.purchaseWithCan( + memberId = 1L, + chatRoomId = 2L, + characterId = 3L, + canOption = ChatRoomQuotaCanOption.CAN_10, + container = "aos" + ) + + Mockito.verify(canPaymentService).spendCan( + 1L, + 10, + CanUsage.CHAT_QUOTA_PURCHASE, + 2L, + 3L, + false, + null, + null, + null, + null, + null, + null, + "aos" + ) + assertEquals(15, result.remainingPaid) + assertEquals(25, result.totalRemaining) + } + + @Test + @DisplayName("20캔 충전은 40개 유료 quota를 지급한다") + fun shouldAddFortyPaidQuotaWhenPurchasingCan20Option() { + val quota = ChatRoomQuota(memberId = 11L, chatRoomId = 12L, characterId = 13L, remainingFree = 10, remainingPaid = 2) + Mockito.`when`(repo.findForUpdate(11L, 12L)).thenReturn(quota) + + val result = service.purchaseWithCan( + memberId = 11L, + chatRoomId = 12L, + characterId = 13L, + canOption = ChatRoomQuotaCanOption.CAN_20, + container = "ios" + ) + + Mockito.verify(canPaymentService).spendCan( + 11L, + 20, + CanUsage.CHAT_QUOTA_PURCHASE, + 12L, + 13L, + false, + null, + null, + null, + null, + null, + null, + "ios" + ) + assertEquals(42, result.remainingPaid) + assertEquals(52, result.totalRemaining) + } + + @Test + @DisplayName("광고 충전은 캔 차감 없이 5개 유료 quota를 지급한다") + fun shouldAddFivePaidQuotaWithoutSpendingCanWhenPurchasingWithAd() { + val quota = ChatRoomQuota(memberId = 21L, chatRoomId = 22L, characterId = 23L, remainingFree = 10, remainingPaid = 1) + Mockito.`when`(repo.findForUpdate(21L, 22L)).thenReturn(quota) + + val result = service.purchaseWithAd( + memberId = 21L, + chatRoomId = 22L, + characterId = 23L + ) + + Mockito.verifyNoInteractions(canPaymentService) + assertEquals(6, result.remainingPaid) + assertEquals(16, result.totalRemaining) + } +}