feat(quota)!: AI 채팅 쿼터(무료/유료) 도입 및 입장/전송 응답에 상태 포함
- ChatQuota 엔티티/레포/서비스/컨트롤러 추가 - 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함 - ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가 - SendChatMessageResponse 신설 및 send API 응답 스키마 변경 - CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영
This commit is contained in:
		| @@ -74,6 +74,7 @@ class CanService(private val repository: CanRepository) { | |||||||
|                     CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" |                     CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" | ||||||
|                     CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" |                     CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" | ||||||
|                     CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" |                     CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" | ||||||
|  |                     CanUsage.CHAT_QUOTA_PURCHASE -> "AI 채팅 개수 구매" | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 val createdAt = it.createdAt!! |                 val createdAt = it.createdAt!! | ||||||
|   | |||||||
| @@ -110,6 +110,9 @@ class CanPaymentService( | |||||||
|             recipientId = liveRoom.member!!.id!! |             recipientId = liveRoom.member!!.id!! | ||||||
|             useCan.room = liveRoom |             useCan.room = liveRoom | ||||||
|             useCan.member = member |             useCan.member = member | ||||||
|  |         } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { | ||||||
|  |             // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 | ||||||
|  |             useCan.member = member | ||||||
|         } else { |         } else { | ||||||
|             throw SodaException("잘못된 요청입니다.") |             throw SodaException("잘못된 요청입니다.") | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -11,5 +11,6 @@ enum class CanUsage { | |||||||
|     ALARM_SLOT, |     ALARM_SLOT, | ||||||
|     AUDITION_VOTE, |     AUDITION_VOTE, | ||||||
|     CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) |     CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) | ||||||
|     CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매 |     CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 | ||||||
|  |     CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,21 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.quota | ||||||
|  |  | ||||||
|  | import java.time.LocalDateTime | ||||||
|  | import javax.persistence.Entity | ||||||
|  | import javax.persistence.Id | ||||||
|  | import javax.persistence.Table | ||||||
|  | import javax.persistence.Version | ||||||
|  |  | ||||||
|  | @Entity | ||||||
|  | @Table(name = "chat_quota") | ||||||
|  | class ChatQuota( | ||||||
|  |     @Id | ||||||
|  |     val memberId: Long, | ||||||
|  |     var remainingFree: Int = 10, | ||||||
|  |     var remainingPaid: Int = 0, | ||||||
|  |     var nextRechargeAt: LocalDateTime? = null, | ||||||
|  |     @Version | ||||||
|  |     var version: Long? = null | ||||||
|  | ) { | ||||||
|  |     fun total(): Int = remainingFree + remainingPaid | ||||||
|  | } | ||||||
| @@ -0,0 +1,65 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.quota | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.can.use.CanUsage | ||||||
|  | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import kr.co.vividnext.sodalive.member.Member | ||||||
|  | import org.springframework.security.core.annotation.AuthenticationPrincipal | ||||||
|  | import org.springframework.web.bind.annotation.GetMapping | ||||||
|  | import org.springframework.web.bind.annotation.PostMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestBody | ||||||
|  | import org.springframework.web.bind.annotation.RequestMapping | ||||||
|  | import org.springframework.web.bind.annotation.RestController | ||||||
|  |  | ||||||
|  | @RestController | ||||||
|  | @RequestMapping("/api/chat/quota") | ||||||
|  | class ChatQuotaController( | ||||||
|  |     private val chatQuotaService: ChatQuotaService, | ||||||
|  |     private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService | ||||||
|  | ) { | ||||||
|  |  | ||||||
|  |     data class ChatQuotaStatusResponse( | ||||||
|  |         val totalRemaining: Int, | ||||||
|  |         val nextRechargeAtEpoch: Long? | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     data class ChatQuotaPurchaseRequest( | ||||||
|  |         val container: String, | ||||||
|  |         val addPaid: Int = 50, | ||||||
|  |         val needCan: Int = 30 | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     @GetMapping("/me") | ||||||
|  |     fun getMyQuota( | ||||||
|  |         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||||
|  |     ): ApiResponse<ChatQuotaStatusResponse> = run { | ||||||
|  |         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||||
|  |  | ||||||
|  |         val s = chatQuotaService.getStatus(member.id!!) | ||||||
|  |         ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @PostMapping("/purchase") | ||||||
|  |     fun purchaseQuota( | ||||||
|  |         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, | ||||||
|  |         @RequestBody request: ChatQuotaPurchaseRequest | ||||||
|  |     ): ApiResponse<Boolean> = run { | ||||||
|  |         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||||
|  |         if (request.container.isBlank()) throw SodaException("container를 확인해주세요.") | ||||||
|  |  | ||||||
|  |         // 30캔 차감 처리 (결제 기록 남김) | ||||||
|  |         canPaymentService.spendCan( | ||||||
|  |             memberId = member.id!!, | ||||||
|  |             needCan = if (request.needCan > 0) request.needCan else 30, | ||||||
|  |             canUsage = CanUsage.CHAT_QUOTA_PURCHASE, | ||||||
|  |             container = request.container | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         // 유료 횟수 적립 (기본 50) | ||||||
|  |         val add = if (request.addPaid > 0) request.addPaid else 50 | ||||||
|  |         chatQuotaService.purchase(member.id!!, add) | ||||||
|  |         ApiResponse.ok(true) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,15 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.quota | ||||||
|  |  | ||||||
|  | import org.springframework.data.jpa.repository.JpaRepository | ||||||
|  | import org.springframework.data.jpa.repository.Lock | ||||||
|  | import org.springframework.data.jpa.repository.Query | ||||||
|  | import org.springframework.data.repository.query.Param | ||||||
|  | import javax.persistence.LockModeType | ||||||
|  |  | ||||||
|  | interface ChatQuotaRepository : JpaRepository<ChatQuota, Long> { | ||||||
|  |     @Lock(LockModeType.PESSIMISTIC_WRITE) | ||||||
|  |     @Query("select q from ChatQuota q where q.memberId = :memberId") | ||||||
|  |     fun findForUpdate(@Param("memberId") memberId: Long): ChatQuota? | ||||||
|  |  | ||||||
|  |     fun findByMemberId(memberId: Long): ChatQuota? | ||||||
|  | } | ||||||
| @@ -0,0 +1,80 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.quota | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import org.springframework.stereotype.Service | ||||||
|  | import org.springframework.transaction.annotation.Transactional | ||||||
|  | import java.time.LocalDateTime | ||||||
|  | import java.time.ZoneId | ||||||
|  |  | ||||||
|  | @Service | ||||||
|  | class ChatQuotaService( | ||||||
|  |     private val repo: ChatQuotaRepository | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         private const val FREE_BUCKET = 10 | ||||||
|  |         private const val RECHARGE_HOURS = 6L | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     data class QuotaStatus( | ||||||
|  |         val totalRemaining: Int, | ||||||
|  |         val nextRechargeAtEpochMillis: Long? | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus { | ||||||
|  |         val now = LocalDateTime.now() | ||||||
|  |         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) | ||||||
|  |         if (quota.remainingFree == 0 && quota.nextRechargeAt != null && !now.isBefore(quota.nextRechargeAt)) { | ||||||
|  |             quota.remainingFree = FREE_BUCKET | ||||||
|  |             quota.nextRechargeAt = null | ||||||
|  |         } | ||||||
|  |         val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() | ||||||
|  |         return QuotaStatus(totalRemaining = quota.total(), nextRechargeAtEpochMillis = epoch) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun consumeOne(memberId: Long) { | ||||||
|  |         val now = LocalDateTime.now() | ||||||
|  |         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) | ||||||
|  |  | ||||||
|  |         when { | ||||||
|  |             quota.remainingFree > 0 -> { | ||||||
|  |                 quota.remainingFree -= 1 | ||||||
|  |                 if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { | ||||||
|  |                     quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             quota.remainingPaid > 0 -> { | ||||||
|  |                 quota.remainingPaid -= 1 | ||||||
|  |                 if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { | ||||||
|  |                     quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             else -> { | ||||||
|  |                 if (quota.nextRechargeAt == null) { | ||||||
|  |                     quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) | ||||||
|  |                 } | ||||||
|  |                 throw SodaException("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun getStatus(memberId: Long): QuotaStatus { | ||||||
|  |         val q = repo.findByMemberId(memberId) ?: return QuotaStatus( | ||||||
|  |             totalRemaining = FREE_BUCKET, | ||||||
|  |             nextRechargeAtEpochMillis = null | ||||||
|  |         ) | ||||||
|  |         val total = q.remainingFree + q.remainingPaid | ||||||
|  |         val epoch = q.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() | ||||||
|  |         return QuotaStatus(totalRemaining = total, nextRechargeAtEpochMillis = epoch) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun purchase(memberId: Long, addPaid: Int) { | ||||||
|  |         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) | ||||||
|  |         quota.remainingPaid += addPaid | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -180,5 +180,16 @@ data class ChatRoomEnterResponse( | |||||||
|     val roomId: Long, |     val roomId: Long, | ||||||
|     val character: ChatRoomEnterCharacterDto, |     val character: ChatRoomEnterCharacterDto, | ||||||
|     val messages: List<ChatMessageItemDto>, |     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? | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -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.ExternalChatSendResponse | ||||||
| import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse | 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.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.ChatMessageRepository | ||||||
| import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository | import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository | ||||||
| import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository | import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository | ||||||
| @@ -49,6 +50,7 @@ class ChatRoomService( | |||||||
|     private val characterImageService: CharacterImageService, |     private val characterImageService: CharacterImageService, | ||||||
|     private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, |     private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, | ||||||
|     private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, |     private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, | ||||||
|  |     private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService, | ||||||
|  |  | ||||||
|     @Value("\${weraser.api-key}") |     @Value("\${weraser.api-key}") | ||||||
|     private val apiKey: String, |     private val apiKey: String, | ||||||
| @@ -335,11 +337,16 @@ class ChatRoomService( | |||||||
|         val messagesAsc = fetched.sortedBy { it.createdAt } |         val messagesAsc = fetched.sortedBy { it.createdAt } | ||||||
|         val items = messagesAsc.map { toChatMessageItemDto(it, member) } |         val items = messagesAsc.map { toChatMessageItemDto(it, member) } | ||||||
|  |  | ||||||
|  |         // 입장 시 Lazy refill 적용 후 상태 반환 | ||||||
|  |         val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) | ||||||
|  |  | ||||||
|         return ChatRoomEnterResponse( |         return ChatRoomEnterResponse( | ||||||
|             roomId = room.id!!, |             roomId = room.id!!, | ||||||
|             character = characterDto, |             character = characterDto, | ||||||
|             messages = items, |             messages = items, | ||||||
|             hasMoreMessages = hasMore |             hasMoreMessages = hasMore, | ||||||
|  |             totalRemaining = quotaStatus.totalRemaining, | ||||||
|  |             nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -490,7 +497,7 @@ class ChatRoomService( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     @Transactional |     @Transactional | ||||||
|     fun sendMessage(member: Member, chatRoomId: Long, message: String): List<ChatMessageItemDto> { |     fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse { | ||||||
|         // 1) 방 존재 확인 |         // 1) 방 존재 확인 | ||||||
|         val room = chatRoomRepository.findById(chatRoomId).orElseThrow { |         val room = chatRoomRepository.findById(chatRoomId).orElseThrow { | ||||||
|             SodaException("채팅방을 찾을 수 없습니다.") |             SodaException("채팅방을 찾을 수 없습니다.") | ||||||
| @@ -512,7 +519,10 @@ class ChatRoomService( | |||||||
|         val sessionId = room.sessionId |         val sessionId = room.sessionId | ||||||
|         val characterUUID = character.characterUUID |         val characterUUID = character.characterUUID | ||||||
|  |  | ||||||
|         // 5) 외부 API 호출 (최대 3회 재시도) |         // 5) 쿼터 확인 및 차감 | ||||||
|  |         chatQuotaService.consumeOne(member.id!!) | ||||||
|  |  | ||||||
|  |         // 6) 외부 API 호출 (최대 3회 재시도) | ||||||
|         val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) |         val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) | ||||||
|  |  | ||||||
|         // 6) 내 메시지 저장 |         // 6) 내 메시지 저장 | ||||||
| @@ -553,6 +563,8 @@ class ChatRoomService( | |||||||
|             hasAccess = true |             hasAccess = true | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         val status = chatQuotaService.getStatus(member.id!!) | ||||||
|  |  | ||||||
|         // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) |         // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) | ||||||
|         val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) |         val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) | ||||||
|         if (matchedImage != null) { |         if (matchedImage != null) { | ||||||
| @@ -579,10 +591,18 @@ class ChatRoomService( | |||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             val imageDto = toChatMessageItemDto(imageMsg, member) |             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( |     private fun toChatMessageItemDto( | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user