feat(chat-quota): 채팅방 쿼터 충전 방식과 옵션을 확장한다

This commit is contained in:
2026-04-29 18:44:36 +09:00
parent 0c0da6cbc9
commit d736ec4368
5 changed files with 422 additions and 14 deletions

View File

@@ -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(
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,
addPaid = 12,
canOption = canOption,
container = req.container
)
}
ChatRoomQuotaChargeType.AD -> {
chatRoomQuotaService.purchaseWithAd(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId
)
}
}
ApiResponse.ok(
PurchaseRoomQuotaResponse(

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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)
}
}