From 6ecac8d331a919f4d4fbe252bcaefa05ba47f7e2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 13:22:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(quota)!:=20AI=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=BF=BC=ED=84=B0(=EB=AC=B4=EB=A3=8C/=EC=9C=A0=EB=A3=8C)=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EC=9E=85=EC=9E=A5/=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatQuota 엔티티/레포/서비스/컨트롤러 추가 - 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함 - ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가 - SendChatMessageResponse 신설 및 send API 응답 스키마 변경 - CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영 --- .../co/vividnext/sodalive/can/CanService.kt | 1 + .../sodalive/can/payment/CanPaymentService.kt | 3 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 3 +- .../sodalive/chat/quota/ChatQuota.kt | 21 +++++ .../chat/quota/ChatQuotaController.kt | 65 +++++++++++++++ .../chat/quota/ChatQuotaRepository.kt | 15 ++++ .../sodalive/chat/quota/ChatQuotaService.kt | 80 +++++++++++++++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 13 ++- .../chat/room/service/ChatRoomService.kt | 30 +++++-- 9 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index c77366a..7038575 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -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!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 275a56d..c9d2498 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -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("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 976845c..4f06828 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -11,5 +11,6 @@ enum class CanUsage { ALARM_SLOT, AUDITION_VOTE, CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) - CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매 + CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 + CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt new file mode 100644 index 0000000..f035202 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt new file mode 100644 index 0000000..b7fe447 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -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 = 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 = 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) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt new file mode 100644 index 0000000..ae84b4b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt @@ -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 { + @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? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt new file mode 100644 index 0000000..6309957 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 8cf1ed4..b43d8a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -180,5 +180,16 @@ data class ChatRoomEnterResponse( val roomId: Long, val character: ChatRoomEnterCharacterDto, val messages: List, - val hasMoreMessages: Boolean + val hasMoreMessages: Boolean, + val totalRemaining: Int, + val nextRechargeAtEpoch: Long? +) + +/** + * 채팅 메시지 전송 응답 DTO (메시지 + 쿼터 상태) + */ +data class SendChatMessageResponse( + val messages: List, + val totalRemaining: Int, + val nextRechargeAtEpoch: Long? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 35b2ed8..6437c41 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -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 { + 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(