캐릭터 챗봇 #338
| @@ -38,6 +38,8 @@ class CanPaymentService( | ||||
|         memberId: Long, | ||||
|         needCan: Int, | ||||
|         canUsage: CanUsage, | ||||
|         chatRoomId: Long? = null, | ||||
|         characterId: Long? = null, | ||||
|         isSecret: Boolean = false, | ||||
|         liveRoom: LiveRoom? = null, | ||||
|         order: Order? = null, | ||||
| @@ -110,12 +112,14 @@ class CanPaymentService( | ||||
|             recipientId = liveRoom.member!!.id!! | ||||
|             useCan.room = liveRoom | ||||
|             useCan.member = member | ||||
|         } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { | ||||
|             // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 | ||||
|         } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) { | ||||
|             useCan.member = member | ||||
|             useCan.chatRoomId = chatRoomId | ||||
|             useCan.characterId = characterId | ||||
|         } else if (canUsage == CanUsage.CHAT_ROOM_RESET) { | ||||
|             // 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속 | ||||
|             useCan.member = member | ||||
|             useCan.chatRoomId = chatRoomId | ||||
|             useCan.characterId = characterId | ||||
|         } else { | ||||
|             throw SodaException("잘못된 요청입니다.") | ||||
|         } | ||||
|   | ||||
| @@ -30,7 +30,11 @@ data class UseCan( | ||||
|  | ||||
|     var isRefund: Boolean = false, | ||||
|  | ||||
|     val isSecret: Boolean = false | ||||
|     val isSecret: Boolean = false, | ||||
|  | ||||
|     // 채팅 연동을 위한 식별자 (옵션) | ||||
|     var chatRoomId: Long? = null, | ||||
|     var characterId: Long? = null | ||||
| ) : BaseEntity() { | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "member_id", nullable = false) | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package kr.co.vividnext.sodalive.chat.quota | ||||
|  | ||||
| import kr.co.vividnext.sodalive.can.payment.CanPaymentService | ||||
| import kr.co.vividnext.sodalive.can.use.CanUsage | ||||
| import kr.co.vividnext.sodalive.common.ApiResponse | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| @@ -15,7 +16,7 @@ import org.springframework.web.bind.annotation.RestController | ||||
| @RequestMapping("/api/chat/quota") | ||||
| class ChatQuotaController( | ||||
|     private val chatQuotaService: ChatQuotaService, | ||||
|     private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService | ||||
|     private val canPaymentService: CanPaymentService | ||||
| ) { | ||||
|  | ||||
|     data class ChatQuotaStatusResponse( | ||||
| @@ -55,10 +56,7 @@ class ChatQuotaController( | ||||
|             container = request.container | ||||
|         ) | ||||
|  | ||||
|         // 유료 횟수 적립 (기본 40) | ||||
|         val add = 40 | ||||
|         chatQuotaService.purchase(member.id!!, add) | ||||
|  | ||||
|         // 글로벌 유료 개념 제거됨: 구매 성공 시에도 글로벌 쿼터 증액 없음 | ||||
|         val s = chatQuotaService.getStatus(member.id!!) | ||||
|         ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) | ||||
|     } | ||||
|   | ||||
| @@ -1,18 +1,18 @@ | ||||
| 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.Instant | ||||
| import java.time.LocalDateTime | ||||
| import java.time.ZoneId | ||||
| import java.time.ZoneOffset | ||||
|  | ||||
| @Service | ||||
| class ChatQuotaService( | ||||
|     private val repo: ChatQuotaRepository | ||||
| ) { | ||||
|     companion object { | ||||
|         private const val FREE_BUCKET = 10 | ||||
|         private const val RECHARGE_HOURS = 1L | ||||
|         private const val FREE_BUCKET = 40 | ||||
|     } | ||||
|  | ||||
|     data class QuotaStatus( | ||||
| @@ -20,63 +20,43 @@ class ChatQuotaService( | ||||
|         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) | ||||
|     private fun nextUtc20LocalDateTime(now: Instant = Instant.now()): LocalDateTime { | ||||
|         val nowUtc = LocalDateTime.ofInstant(now, ZoneOffset.UTC) | ||||
|         val today20 = nowUtc.withHour(20).withMinute(0).withSecond(0).withNano(0) | ||||
|         val target = if (nowUtc.isBefore(today20)) today20 else today20.plusDays(1) | ||||
|         // 저장은 시스템 기본 타임존의 LocalDateTime으로 보관 | ||||
|         return LocalDateTime.ofInstant(target.toInstant(ZoneOffset.UTC), ZoneId.systemDefault()) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun consumeOne(memberId: Long) { | ||||
|         val now = LocalDateTime.now() | ||||
|     fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus { | ||||
|         val now = Instant.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.plusMinutes(RECHARGE_HOURS) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             quota.remainingPaid > 0 -> { | ||||
|                 quota.remainingPaid -= 1 | ||||
|                 if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { | ||||
|                     quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             else -> { | ||||
|                 if (quota.nextRechargeAt == null) { | ||||
|                     quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) | ||||
|                 } | ||||
|                 throw SodaException("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.") | ||||
|             } | ||||
|         // Lazy refill: nextRechargeAt이 없거나 현재를 지났다면 무료 40 회복 | ||||
|         val nextRecharge = nextUtc20LocalDateTime(now) | ||||
|         if (quota.nextRechargeAt == null || !LocalDateTime.now().isBefore(quota.nextRechargeAt)) { | ||||
|             quota.remainingFree = FREE_BUCKET | ||||
|         } | ||||
|         // 다음 UTC20 기준 시간으로 항상 갱신 | ||||
|         quota.nextRechargeAt = nextRecharge | ||||
|  | ||||
|         val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() | ||||
|         // 글로벌은 유료 개념 제거: totalRemaining은 remainingFree만 사용 | ||||
|         return QuotaStatus(totalRemaining = quota.remainingFree, nextRechargeAtEpochMillis = epoch) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun consumeOneFree(memberId: Long) { | ||||
|         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) | ||||
|         if (quota.remainingFree <= 0) { | ||||
|             // 소비 불가: 호출자는 상태 조회로 남은 시간을 판단 | ||||
|             throw IllegalStateException("No global free quota") | ||||
|         } | ||||
|         quota.remainingFree -= 1 | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun getStatus(memberId: Long): QuotaStatus { | ||||
|         return applyRefillOnEnterAndGetStatus(memberId) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun purchase(memberId: Long, addPaid: Int) { | ||||
|         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) | ||||
|         quota.remainingPaid += addPaid | ||||
|         quota.nextRechargeAt = null | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun resetFreeToDefault(memberId: Long) { | ||||
|         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) | ||||
|         quota.remainingFree = FREE_BUCKET | ||||
|         quota.nextRechargeAt = null | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,19 @@ | ||||
| package kr.co.vividnext.sodalive.chat.quota.room | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.Table | ||||
| import javax.persistence.Version | ||||
|  | ||||
| @Entity | ||||
| @Table(name = "chat_room_quota") | ||||
| class ChatRoomQuota( | ||||
|     val memberId: Long, | ||||
|     val chatRoomId: Long, | ||||
|     val characterId: Long, | ||||
|     var remainingFree: Int = 10, | ||||
|     var remainingPaid: Int = 0, | ||||
|     var nextRechargeAt: Long? = null, | ||||
|     @Version | ||||
|     var version: Long? = null | ||||
| ) : BaseEntity() | ||||
| @@ -0,0 +1,139 @@ | ||||
| package kr.co.vividnext.sodalive.chat.quota.room | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService | ||||
| 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.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.PathVariable | ||||
| 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/rooms") | ||||
| class ChatRoomQuotaController( | ||||
|     private val chatRoomRepository: ChatRoomRepository, | ||||
|     private val participantRepository: ChatParticipantRepository, | ||||
|     private val chatRoomQuotaService: ChatRoomQuotaService, | ||||
|     private val chatQuotaService: ChatQuotaService | ||||
| ) { | ||||
|  | ||||
|     data class PurchaseRoomQuotaRequest( | ||||
|         val container: String | ||||
|     ) | ||||
|  | ||||
|     data class PurchaseRoomQuotaResponse( | ||||
|         val totalRemaining: Int, | ||||
|         val nextRechargeAtEpoch: Long?, | ||||
|         val remainingFree: Int, | ||||
|         val remainingPaid: Int | ||||
|     ) | ||||
|  | ||||
|     data class RoomQuotaStatusResponse( | ||||
|         val totalRemaining: Int, | ||||
|         val nextRechargeAtEpoch: Long? | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * 채팅방 유료 쿼터 구매 API | ||||
|      * - 참여 여부 검증(내가 USER로 참여 중인 활성 방) | ||||
|      * - 30캔 결제 (UseCan에 chatRoomId:characterId 기록) | ||||
|      * - 방 유료 쿼터 40 충전 | ||||
|      */ | ||||
|     @PostMapping("/{chatRoomId}/quota/purchase") | ||||
|     fun purchaseRoomQuota( | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, | ||||
|         @PathVariable chatRoomId: Long, | ||||
|         @RequestBody req: PurchaseRoomQuotaRequest | ||||
|     ): ApiResponse<PurchaseRoomQuotaResponse> = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||
|         if (req.container.isBlank()) throw SodaException("container를 확인해주세요.") | ||||
|  | ||||
|         val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) | ||||
|             ?: throw SodaException("채팅방을 찾을 수 없습니다.") | ||||
|  | ||||
|         // 내 참여 여부 확인 | ||||
|         participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) | ||||
|             ?: throw SodaException("잘못된 접근입니다") | ||||
|  | ||||
|         // 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조) | ||||
|         val characterParticipant = participantRepository | ||||
|             .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) | ||||
|             ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") | ||||
|  | ||||
|         val character = characterParticipant.character | ||||
|             ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") | ||||
|  | ||||
|         val characterId = character.id | ||||
|             ?: throw SodaException("잘못된 요청입니다. 캐릭터 정보를 확인해주세요.") | ||||
|  | ||||
|         // 서비스에서 결제 포함하여 처리 | ||||
|         val status = chatRoomQuotaService.purchase( | ||||
|             memberId = member.id!!, | ||||
|             chatRoomId = chatRoomId, | ||||
|             characterId = characterId, | ||||
|             addPaid = 40, | ||||
|             container = req.container | ||||
|         ) | ||||
|  | ||||
|         ApiResponse.ok( | ||||
|             PurchaseRoomQuotaResponse( | ||||
|                 totalRemaining = status.totalRemaining, | ||||
|                 nextRechargeAtEpoch = status.nextRechargeAtEpochMillis, | ||||
|                 remainingFree = status.remainingFree, | ||||
|                 remainingPaid = status.remainingPaid | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @GetMapping("/{chatRoomId}/quota/me") | ||||
|     fun getMyRoomQuota( | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, | ||||
|         @PathVariable chatRoomId: Long | ||||
|     ): ApiResponse<RoomQuotaStatusResponse> = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||
|  | ||||
|         val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) | ||||
|             ?: throw SodaException("채팅방을 찾을 수 없습니다.") | ||||
|         // 내 참여 여부 확인 | ||||
|         participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) | ||||
|             ?: throw SodaException("잘못된 접근입니다") | ||||
|         // 캐릭터 확인 | ||||
|         val characterParticipant = participantRepository | ||||
|             .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) | ||||
|             ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") | ||||
|         val character = characterParticipant.character | ||||
|             ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") | ||||
|  | ||||
|         // 글로벌 Lazy refill | ||||
|         val globalStatus = chatQuotaService.getStatus(member.id!!) | ||||
|  | ||||
|         // 룸 Lazy refill 상태 | ||||
|         val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus( | ||||
|             memberId = member.id!!, | ||||
|             chatRoomId = chatRoomId, | ||||
|             characterId = character.id!!, | ||||
|             globalFree = globalStatus.totalRemaining | ||||
|         ) | ||||
|  | ||||
|         val next: Long? = when { | ||||
|             roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis | ||||
|             globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis | ||||
|             else -> null | ||||
|         } | ||||
|         ApiResponse.ok( | ||||
|             RoomQuotaStatusResponse( | ||||
|                 totalRemaining = roomStatus.totalRemaining, | ||||
|                 nextRechargeAtEpoch = next | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| package kr.co.vividnext.sodalive.chat.quota.room | ||||
|  | ||||
| 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 ChatRoomQuotaRepository : JpaRepository<ChatRoomQuota, Long> { | ||||
|     @Lock(LockModeType.PESSIMISTIC_WRITE) | ||||
|     @Query("select q from ChatRoomQuota q where q.memberId = :memberId and q.chatRoomId = :chatRoomId") | ||||
|     fun findForUpdate( | ||||
|         @Param("memberId") memberId: Long, | ||||
|         @Param("chatRoomId") chatRoomId: Long | ||||
|     ): ChatRoomQuota? | ||||
| } | ||||
| @@ -0,0 +1,172 @@ | ||||
| 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 kr.co.vividnext.sodalive.common.SodaException | ||||
| import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | ||||
| import java.time.Duration | ||||
| import java.time.Instant | ||||
|  | ||||
| @Service | ||||
| class ChatRoomQuotaService( | ||||
|     private val repo: ChatRoomQuotaRepository, | ||||
|     private val canPaymentService: CanPaymentService | ||||
| ) { | ||||
|     data class RoomQuotaStatus( | ||||
|         val totalRemaining: Int, | ||||
|         val nextRechargeAtEpochMillis: Long?, | ||||
|         val remainingFree: Int, | ||||
|         val remainingPaid: Int | ||||
|     ) | ||||
|  | ||||
|     private fun calculateAvailableForRoom(globalFree: Int, roomFree: Int, roomPaid: Int): Int { | ||||
|         // 유료가 있으면 글로벌 상관 없이 (유료 + 무료동시가능수)로 계산 | ||||
|         // 무료만 있는 경우에는 글로벌과 룸 Free의 교집합으로 사용 가능 횟수 계산 | ||||
|         val freeUsable = minOf(globalFree, roomFree) | ||||
|         return roomPaid + freeUsable | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun applyRefillOnEnterAndGetStatus( | ||||
|         memberId: Long, | ||||
|         chatRoomId: Long, | ||||
|         characterId: Long, | ||||
|         globalFree: Int | ||||
|     ): RoomQuotaStatus { | ||||
|         val now = Instant.now() | ||||
|         val nowMillis = now.toEpochMilli() | ||||
|         val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save( | ||||
|             ChatRoomQuota( | ||||
|                 memberId = memberId, | ||||
|                 chatRoomId = chatRoomId, | ||||
|                 characterId = characterId, | ||||
|                 remainingFree = 10, | ||||
|                 remainingPaid = 0, | ||||
|                 nextRechargeAt = null | ||||
|             ) | ||||
|         ) | ||||
|         // Lazy refill: nextRechargeAt이 현재를 지났으면 무료 10으로 리셋하고 next=null | ||||
|         if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) { | ||||
|             quota.remainingFree = 10 | ||||
|             quota.nextRechargeAt = null | ||||
|         } | ||||
|  | ||||
|         val total = calculateAvailableForRoom( | ||||
|             globalFree = globalFree, | ||||
|             roomFree = quota.remainingFree, | ||||
|             roomPaid = quota.remainingPaid | ||||
|         ) | ||||
|         return RoomQuotaStatus( | ||||
|             totalRemaining = total, | ||||
|             nextRechargeAtEpochMillis = quota.nextRechargeAt, | ||||
|             remainingFree = quota.remainingFree, | ||||
|             remainingPaid = quota.remainingPaid | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun consumeOneForSend( | ||||
|         memberId: Long, | ||||
|         chatRoomId: Long, | ||||
|         globalFreeProvider: () -> Int, | ||||
|         consumeGlobalFree: () -> Unit | ||||
|     ): RoomQuotaStatus { | ||||
|         val now = Instant.now() | ||||
|         val nowMillis = now.toEpochMilli() | ||||
|         val quota = repo.findForUpdate(memberId, chatRoomId) | ||||
|             ?: throw SodaException("채팅방을 찾을 수 없습니다.") | ||||
|  | ||||
|         // 충전 시간이 지났다면 무료 10으로 리셋하고 next=null | ||||
|         if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) { | ||||
|             quota.remainingFree = 10 | ||||
|             quota.nextRechargeAt = null | ||||
|         } | ||||
|  | ||||
|         // 1) 유료 우선 사용: 글로벌에 영향 없음 | ||||
|         if (quota.remainingPaid > 0) { | ||||
|             quota.remainingPaid -= 1 | ||||
|             val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid) | ||||
|             return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid) | ||||
|         } | ||||
|  | ||||
|         // 2) 무료 사용: 글로벌과 룸 동시에 조건 충족 필요 | ||||
|         val globalFree = globalFreeProvider() | ||||
|         if (globalFree <= 0) { | ||||
|             // 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가 | ||||
|             throw SodaException("무료 쿼터가 소진되었습니다. 글로벌 무료 충전 이후 이용해 주세요.") | ||||
|         } | ||||
|         if (quota.remainingFree <= 0) { | ||||
|             // 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가 | ||||
|             val waitMillis = quota.nextRechargeAt | ||||
|             if (waitMillis != null && waitMillis > nowMillis) { | ||||
|                 throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 무료 충전 이후 이용해 주세요.") | ||||
|             } else { | ||||
|                 throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 잠시 후 다시 시도해 주세요.") | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // 둘 다 가능 → 차감 | ||||
|         consumeGlobalFree() | ||||
|         quota.remainingFree -= 1 | ||||
|         if (quota.remainingFree == 0) { | ||||
|             // 무료가 0이 되는 순간 nextRechargeAt = 현재 + 6시간 | ||||
|             quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli() | ||||
|         } | ||||
|         val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid) | ||||
|         return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun purchase( | ||||
|         memberId: Long, | ||||
|         chatRoomId: Long, | ||||
|         characterId: Long, | ||||
|         addPaid: Int = 40, | ||||
|         container: String | ||||
|     ): RoomQuotaStatus { | ||||
|         // 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록 | ||||
|         canPaymentService.spendCan( | ||||
|             memberId = memberId, | ||||
|             needCan = 30, | ||||
|             canUsage = CanUsage.CHAT_QUOTA_PURCHASE, | ||||
|             chatRoomId = chatRoomId, | ||||
|             characterId = characterId, | ||||
|             container = container | ||||
|         ) | ||||
|  | ||||
|         val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save( | ||||
|             ChatRoomQuota( | ||||
|                 memberId = memberId, | ||||
|                 chatRoomId = chatRoomId, | ||||
|                 characterId = characterId | ||||
|             ) | ||||
|         ) | ||||
|         quota.remainingPaid += addPaid | ||||
|         quota.nextRechargeAt = null | ||||
|  | ||||
|         val total = quota.remainingPaid + quota.remainingFree | ||||
|         return RoomQuotaStatus( | ||||
|             totalRemaining = total, | ||||
|             nextRechargeAtEpochMillis = quota.nextRechargeAt, | ||||
|             remainingFree = quota.remainingFree, | ||||
|             remainingPaid = quota.remainingPaid | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun transferPaid(memberId: Long, fromChatRoomId: Long, toChatRoomId: Long, toCharacterId: Long) { | ||||
|         val from = repo.findForUpdate(memberId, fromChatRoomId) ?: return | ||||
|         if (from.remainingPaid <= 0) return | ||||
|         val to = repo.findForUpdate(memberId, toChatRoomId) ?: repo.save( | ||||
|             ChatRoomQuota( | ||||
|                 memberId = memberId, | ||||
|                 chatRoomId = toChatRoomId, | ||||
|                 characterId = toCharacterId | ||||
|             ) | ||||
|         ) | ||||
|         to.remainingPaid += from.remainingPaid | ||||
|         from.remainingPaid = 0 | ||||
|         // 유료 이관은 룸 무료 충전 시간에 영향을 주지 않음 | ||||
|     } | ||||
| } | ||||
| @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage | ||||
| import kr.co.vividnext.sodalive.chat.character.image.CharacterImage | ||||
| import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService | ||||
| import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | ||||
| import kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaService | ||||
| import kr.co.vividnext.sodalive.chat.room.ChatMessage | ||||
| import kr.co.vividnext.sodalive.chat.room.ChatMessageType | ||||
| import kr.co.vividnext.sodalive.chat.room.ChatParticipant | ||||
| @@ -52,6 +53,7 @@ class ChatRoomService( | ||||
|     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, | ||||
|     private val chatRoomQuotaService: ChatRoomQuotaService, | ||||
|  | ||||
|     @Value("\${weraser.api-key}") | ||||
|     private val apiKey: String, | ||||
| @@ -376,8 +378,15 @@ class ChatRoomService( | ||||
|         val messagesAsc = fetched.sortedBy { it.createdAt } | ||||
|         val items = messagesAsc.map { toChatMessageItemDto(it, member) } | ||||
|  | ||||
|         // 입장 시 Lazy refill 적용 후 상태 반환 | ||||
|         val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) | ||||
|         // 5-1) 글로벌 쿼터 Lazy refill | ||||
|         val globalStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) | ||||
|         // 5-2) 룸 쿼터 Lazy refill + 상태 | ||||
|         val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus( | ||||
|             memberId = member.id!!, | ||||
|             chatRoomId = effectiveRoom.id!!, | ||||
|             characterId = character.id!!, | ||||
|             globalFree = globalStatus.totalRemaining | ||||
|         ) | ||||
|  | ||||
|         // 선택적 캐릭터 이미지 서명 URL 생성 처리 | ||||
|         // 요구사항: baseRoom이 조건 불만족으로 동일 캐릭터의 내 활성 방으로 라우팅된 경우(bg 이미지 요청 무시)에는 null로 처리 | ||||
| @@ -419,13 +428,42 @@ class ChatRoomService( | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         // 권고안에 따른 next 계산 | ||||
|         val nextForEnter: Long? = when { | ||||
|             // roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next | ||||
|             roomStatus.remainingPaid == 0 && roomStatus.remainingFree > 0 && globalStatus.totalRemaining <= 0 -> | ||||
|                 globalStatus.nextRechargeAtEpochMillis | ||||
|             // roomPaid==0 && roomFree==0 → (global<=0) ? max(roomNext, globalNext) : roomNext | ||||
|             roomStatus.remainingPaid == 0 && roomStatus.remainingFree == 0 -> { | ||||
|                 val roomNext = roomStatus.nextRechargeAtEpochMillis | ||||
|                 val globalNext = globalStatus.nextRechargeAtEpochMillis | ||||
|                 if (globalStatus.totalRemaining <= 0) { | ||||
|                     if (roomNext == null) { | ||||
|                         globalNext | ||||
|                     } else if (globalNext == null) { | ||||
|                         roomNext | ||||
|                     } else { | ||||
|                         maxOf( | ||||
|                             roomNext, | ||||
|                             globalNext | ||||
|                         ) | ||||
|                     } | ||||
|                 } else { | ||||
|                     roomNext | ||||
|                 } | ||||
|             } | ||||
|             // 그 외 기존 규칙: room total==0 → room next, else if global<=0 → global next, else null | ||||
|             roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis | ||||
|             globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis | ||||
|             else -> null | ||||
|         } | ||||
|         return ChatRoomEnterResponse( | ||||
|             roomId = effectiveRoom.id!!, | ||||
|             character = characterDto, | ||||
|             messages = items, | ||||
|             hasMoreMessages = hasMore, | ||||
|             totalRemaining = quotaStatus.totalRemaining, | ||||
|             nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis, | ||||
|             totalRemaining = roomStatus.totalRemaining, | ||||
|             nextRechargeAtEpoch = nextForEnter, | ||||
|             bgImageUrl = signedUrl | ||||
|         ) | ||||
|     } | ||||
| @@ -602,8 +640,13 @@ class ChatRoomService( | ||||
|         val sessionId = room.sessionId | ||||
|         val characterUUID = character.characterUUID | ||||
|  | ||||
|         // 5) 쿼터 확인 및 차감 | ||||
|         chatQuotaService.consumeOne(member.id!!) | ||||
|         // 5) 쿼터 확인 및 차감 (유료 우선, 무료 사용 시 글로벌과 룸 동시 차감) | ||||
|         val roomQuotaAfterConsume = chatRoomQuotaService.consumeOneForSend( | ||||
|             memberId = member.id!!, | ||||
|             chatRoomId = room.id!!, | ||||
|             globalFreeProvider = { chatQuotaService.getStatus(member.id!!).totalRemaining }, | ||||
|             consumeGlobalFree = { chatQuotaService.consumeOneFree(member.id!!) } | ||||
|         ) | ||||
|  | ||||
|         // 6) 외부 API 호출 (최대 3회 재시도) | ||||
|         val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) | ||||
| @@ -646,7 +689,21 @@ class ChatRoomService( | ||||
|             hasAccess = true | ||||
|         ) | ||||
|  | ||||
|         val status = chatQuotaService.getStatus(member.id!!) | ||||
|         // 발송 후 최신 잔여 수량 및 next 계산 규칙 적용 | ||||
|         val statusTotalRemaining = roomQuotaAfterConsume.totalRemaining | ||||
|         val globalAfter = chatQuotaService.getStatus(member.id!!) | ||||
|         val statusNextRechargeAt: Long? = when { | ||||
|             // totalRemaining==0이고 (global<=0) → max(roomNext, globalNext) | ||||
|             statusTotalRemaining == 0 && globalAfter.totalRemaining <= 0 -> { | ||||
|                 val roomNext = roomQuotaAfterConsume.nextRechargeAtEpochMillis | ||||
|                 val globalNext = globalAfter.nextRechargeAtEpochMillis | ||||
|                 if (roomNext == null) globalNext else if (globalNext == null) roomNext else maxOf(roomNext, globalNext) | ||||
|             } | ||||
|  | ||||
|             statusTotalRemaining == 0 -> roomQuotaAfterConsume.nextRechargeAtEpochMillis | ||||
|             globalAfter.totalRemaining <= 0 -> globalAfter.nextRechargeAtEpochMillis | ||||
|             else -> null | ||||
|         } | ||||
|  | ||||
|         // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) | ||||
|         val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) | ||||
| @@ -676,15 +733,15 @@ class ChatRoomService( | ||||
|             val imageDto = toChatMessageItemDto(imageMsg, member) | ||||
|             return SendChatMessageResponse( | ||||
|                 messages = listOf(textDto, imageDto), | ||||
|                 totalRemaining = status.totalRemaining, | ||||
|                 nextRechargeAtEpoch = status.nextRechargeAtEpochMillis | ||||
|                 totalRemaining = statusTotalRemaining, | ||||
|                 nextRechargeAtEpoch = statusNextRechargeAt | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         return SendChatMessageResponse( | ||||
|             messages = listOf(textDto), | ||||
|             totalRemaining = status.totalRemaining, | ||||
|             nextRechargeAtEpoch = status.nextRechargeAtEpochMillis | ||||
|             totalRemaining = statusTotalRemaining, | ||||
|             nextRechargeAtEpoch = statusNextRechargeAt | ||||
|         ) | ||||
|     } | ||||
|  | ||||
| @@ -846,6 +903,8 @@ class ChatRoomService( | ||||
|             memberId = member.id!!, | ||||
|             needCan = 30, | ||||
|             canUsage = CanUsage.CHAT_ROOM_RESET, | ||||
|             chatRoomId = chatRoomId, | ||||
|             characterId = character.id!!, | ||||
|             container = container | ||||
|         ) | ||||
|  | ||||
| @@ -855,10 +914,14 @@ class ChatRoomService( | ||||
|         // 4) 동일한 캐릭터와 새로운 채팅방 생성 | ||||
|         val created = createOrGetChatRoom(member, character.id!!) | ||||
|  | ||||
|         // 5) 신규 채팅방 생성 성공 시 무료 채팅 횟수 10으로 설정 | ||||
|         chatQuotaService.resetFreeToDefault(member.id!!) | ||||
|  | ||||
|         // 6) 생성된 채팅방 데이터 반환 | ||||
|         // 5) 신규 채팅방 생성 성공 시: 기존 방의 유료 쿼터를 새 방으로 이관 | ||||
|         chatRoomQuotaService.transferPaid( | ||||
|             memberId = member.id!!, | ||||
|             fromChatRoomId = chatRoomId, | ||||
|             toChatRoomId = created.chatRoomId, | ||||
|             toCharacterId = character.id!! | ||||
|         ) | ||||
|         // 글로벌 무료 쿼터는 UTC 20:00 기준 lazy 충전이므로 별도의 초기화 불필요 | ||||
|         return created | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user