feat(chat-quota): 채팅방 쿼터 충전 방식과 옵션을 확장한다
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user