feat(quota)!: AI 채팅 쿼터(무료/유료) 도입 및 입장/전송 응답에 상태 포함

- ChatQuota 엔티티/레포/서비스/컨트롤러 추가
- 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함
- ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가
- SendChatMessageResponse 신설 및 send API 응답 스키마 변경
- CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영
This commit is contained in:
2025-08-26 13:22:49 +09:00
parent 8b1dd7cb95
commit 6ecac8d331
9 changed files with 224 additions and 7 deletions

View File

@@ -180,5 +180,16 @@ data class ChatRoomEnterResponse(
val roomId: Long,
val character: ChatRoomEnterCharacterDto,
val messages: List<ChatMessageItemDto>,
val hasMoreMessages: Boolean
val hasMoreMessages: Boolean,
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?
)
/**
* 채팅 메시지 전송 응답 DTO (메시지 + 쿼터 상태)
*/
data class SendChatMessageResponse(
val messages: List<ChatMessageItemDto>,
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?
)

View File

@@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
@@ -49,6 +50,7 @@ class ChatRoomService(
private val characterImageService: CharacterImageService,
private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService,
private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront,
private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService,
@Value("\${weraser.api-key}")
private val apiKey: String,
@@ -335,11 +337,16 @@ class ChatRoomService(
val messagesAsc = fetched.sortedBy { it.createdAt }
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
// 입장 시 Lazy refill 적용 후 상태 반환
val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!)
return ChatRoomEnterResponse(
roomId = room.id!!,
character = characterDto,
messages = items,
hasMoreMessages = hasMore
hasMoreMessages = hasMore,
totalRemaining = quotaStatus.totalRemaining,
nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis
)
}
@@ -490,7 +497,7 @@ class ChatRoomService(
}
@Transactional
fun sendMessage(member: Member, chatRoomId: Long, message: String): List<ChatMessageItemDto> {
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
// 1) 방 존재 확인
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
@@ -512,7 +519,10 @@ class ChatRoomService(
val sessionId = room.sessionId
val characterUUID = character.characterUUID
// 5) 외부 API 호출 (최대 3회 재시도)
// 5) 쿼터 확인 및 차감
chatQuotaService.consumeOne(member.id!!)
// 6) 외부 API 호출 (최대 3회 재시도)
val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
// 6) 내 메시지 저장
@@ -553,6 +563,8 @@ class ChatRoomService(
hasAccess = true
)
val status = chatQuotaService.getStatus(member.id!!)
// 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우)
val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply)
if (matchedImage != null) {
@@ -579,10 +591,18 @@ class ChatRoomService(
)
val imageDto = toChatMessageItemDto(imageMsg, member)
return listOf(textDto, imageDto)
return SendChatMessageResponse(
messages = listOf(textDto, imageDto),
totalRemaining = status.totalRemaining,
nextRechargeAtEpoch = status.nextRechargeAtEpochMillis
)
}
return listOf(textDto)
return SendChatMessageResponse(
messages = listOf(textDto),
totalRemaining = status.totalRemaining,
nextRechargeAtEpoch = status.nextRechargeAtEpochMillis
)
}
private fun toChatMessageItemDto(