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 ce57b14..b34f886 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 @@ -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("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt index 3d0fc46..dfb0b2b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt @@ -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) 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 index fa0640e..c82a281 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -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)) } 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 index d62c822..b3685ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -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 - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuota.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuota.kt new file mode 100644 index 0000000..ec5687e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuota.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt new file mode 100644 index 0000000..3064bc1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt @@ -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 = 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 = 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 + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaRepository.kt new file mode 100644 index 0000000..962d8a2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaRepository.kt @@ -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 { + @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? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt new file mode 100644 index 0000000..db24ea6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt @@ -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 + // 유료 이관은 룸 무료 충전 시간에 영향을 주지 않음 + } +} 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 9b24ba9..1835fe5 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 @@ -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 } }