캐릭터 챗봇 #338
| @@ -74,6 +74,7 @@ class CanService(private val repository: CanRepository) { | ||||
|                     CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" | ||||
|                     CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" | ||||
|                     CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" | ||||
|                     CanUsage.CHAT_QUOTA_PURCHASE -> "AI 채팅 개수 구매" | ||||
|                 } | ||||
|  | ||||
|                 val createdAt = it.createdAt!! | ||||
|   | ||||
| @@ -110,6 +110,9 @@ class CanPaymentService( | ||||
|             recipientId = liveRoom.member!!.id!! | ||||
|             useCan.room = liveRoom | ||||
|             useCan.member = member | ||||
|         } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { | ||||
|             // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 | ||||
|             useCan.member = member | ||||
|         } else { | ||||
|             throw SodaException("잘못된 요청입니다.") | ||||
|         } | ||||
|   | ||||
| @@ -11,5 +11,6 @@ enum class CanUsage { | ||||
|     ALARM_SLOT, | ||||
|     AUDITION_VOTE, | ||||
|     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 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? | ||||
| ) | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user