feat(chat): 글로벌/방 쿼터 정책 개편, 결제/조회/차단/이관 로직 반영
글로벌: 무료 40, UTC 20:00 lazy refill(유료 제거) 방: 무료 10, 무료 0 순간 now+6h, 경과 시 lazy refill(무료=10, next=null) 전송: 유료 우선, 무료 사용 시 글로벌/룸 동시 차감, 조건 불충족 예외 API: 방 쿼터 조회/구매 추가(구매 시 30캔, UseCan에 roomId:characterId 기록) next 계산: enter/send에서 경계 케이스 처리(max(room, global)) 대화 초기화: 유료 쿼터 새 방으로 이관
This commit is contained in:
		| @@ -38,6 +38,8 @@ class CanPaymentService( | |||||||
|         memberId: Long, |         memberId: Long, | ||||||
|         needCan: Int, |         needCan: Int, | ||||||
|         canUsage: CanUsage, |         canUsage: CanUsage, | ||||||
|  |         chatRoomId: Long? = null, | ||||||
|  |         characterId: Long? = null, | ||||||
|         isSecret: Boolean = false, |         isSecret: Boolean = false, | ||||||
|         liveRoom: LiveRoom? = null, |         liveRoom: LiveRoom? = null, | ||||||
|         order: Order? = null, |         order: Order? = null, | ||||||
| @@ -110,12 +112,14 @@ 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) { |         } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) { | ||||||
|             // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 |  | ||||||
|             useCan.member = member |             useCan.member = member | ||||||
|  |             useCan.chatRoomId = chatRoomId | ||||||
|  |             useCan.characterId = characterId | ||||||
|         } else if (canUsage == CanUsage.CHAT_ROOM_RESET) { |         } else if (canUsage == CanUsage.CHAT_ROOM_RESET) { | ||||||
|             // 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속 |  | ||||||
|             useCan.member = member |             useCan.member = member | ||||||
|  |             useCan.chatRoomId = chatRoomId | ||||||
|  |             useCan.characterId = characterId | ||||||
|         } else { |         } else { | ||||||
|             throw SodaException("잘못된 요청입니다.") |             throw SodaException("잘못된 요청입니다.") | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -30,7 +30,11 @@ data class UseCan( | |||||||
|  |  | ||||||
|     var isRefund: Boolean = false, |     var isRefund: Boolean = false, | ||||||
|  |  | ||||||
|     val isSecret: Boolean = false |     val isSecret: Boolean = false, | ||||||
|  |  | ||||||
|  |     // 채팅 연동을 위한 식별자 (옵션) | ||||||
|  |     var chatRoomId: Long? = null, | ||||||
|  |     var characterId: Long? = null | ||||||
| ) : BaseEntity() { | ) : BaseEntity() { | ||||||
|     @ManyToOne(fetch = FetchType.LAZY) |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|     @JoinColumn(name = "member_id", nullable = false) |     @JoinColumn(name = "member_id", nullable = false) | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.quota | 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.can.use.CanUsage | ||||||
| import kr.co.vividnext.sodalive.common.ApiResponse | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
| import kr.co.vividnext.sodalive.common.SodaException | import kr.co.vividnext.sodalive.common.SodaException | ||||||
| @@ -15,7 +16,7 @@ import org.springframework.web.bind.annotation.RestController | |||||||
| @RequestMapping("/api/chat/quota") | @RequestMapping("/api/chat/quota") | ||||||
| class ChatQuotaController( | class ChatQuotaController( | ||||||
|     private val chatQuotaService: ChatQuotaService, |     private val chatQuotaService: ChatQuotaService, | ||||||
|     private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService |     private val canPaymentService: CanPaymentService | ||||||
| ) { | ) { | ||||||
|  |  | ||||||
|     data class ChatQuotaStatusResponse( |     data class ChatQuotaStatusResponse( | ||||||
| @@ -55,10 +56,7 @@ class ChatQuotaController( | |||||||
|             container = request.container |             container = request.container | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         // 유료 횟수 적립 (기본 40) |         // 글로벌 유료 개념 제거됨: 구매 성공 시에도 글로벌 쿼터 증액 없음 | ||||||
|         val add = 40 |  | ||||||
|         chatQuotaService.purchase(member.id!!, add) |  | ||||||
|  |  | ||||||
|         val s = chatQuotaService.getStatus(member.id!!) |         val s = chatQuotaService.getStatus(member.id!!) | ||||||
|         ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) |         ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,18 +1,18 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.quota | package kr.co.vividnext.sodalive.chat.quota | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.common.SodaException |  | ||||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||||
|  | import java.time.Instant | ||||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||||
| import java.time.ZoneId | import java.time.ZoneId | ||||||
|  | import java.time.ZoneOffset | ||||||
|  |  | ||||||
| @Service | @Service | ||||||
| class ChatQuotaService( | class ChatQuotaService( | ||||||
|     private val repo: ChatQuotaRepository |     private val repo: ChatQuotaRepository | ||||||
| ) { | ) { | ||||||
|     companion object { |     companion object { | ||||||
|         private const val FREE_BUCKET = 10 |         private const val FREE_BUCKET = 40 | ||||||
|         private const val RECHARGE_HOURS = 1L |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     data class QuotaStatus( |     data class QuotaStatus( | ||||||
| @@ -20,63 +20,43 @@ class ChatQuotaService( | |||||||
|         val nextRechargeAtEpochMillis: Long? |         val nextRechargeAtEpochMillis: Long? | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     @Transactional |     private fun nextUtc20LocalDateTime(now: Instant = Instant.now()): LocalDateTime { | ||||||
|     fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus { |         val nowUtc = LocalDateTime.ofInstant(now, ZoneOffset.UTC) | ||||||
|         val now = LocalDateTime.now() |         val today20 = nowUtc.withHour(20).withMinute(0).withSecond(0).withNano(0) | ||||||
|         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) |         val target = if (nowUtc.isBefore(today20)) today20 else today20.plusDays(1) | ||||||
|         if (quota.remainingFree == 0 && quota.nextRechargeAt != null && !now.isBefore(quota.nextRechargeAt)) { |         // 저장은 시스템 기본 타임존의 LocalDateTime으로 보관 | ||||||
|             quota.remainingFree = FREE_BUCKET |         return LocalDateTime.ofInstant(target.toInstant(ZoneOffset.UTC), ZoneId.systemDefault()) | ||||||
|             quota.nextRechargeAt = null |  | ||||||
|         } |  | ||||||
|         val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() |  | ||||||
|         return QuotaStatus(totalRemaining = quota.total(), nextRechargeAtEpochMillis = epoch) |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     @Transactional |     @Transactional | ||||||
|     fun consumeOne(memberId: Long) { |     fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus { | ||||||
|         val now = LocalDateTime.now() |         val now = Instant.now() | ||||||
|         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) |         val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) | ||||||
|  |         // Lazy refill: nextRechargeAt이 없거나 현재를 지났다면 무료 40 회복 | ||||||
|         when { |         val nextRecharge = nextUtc20LocalDateTime(now) | ||||||
|             quota.remainingFree > 0 -> { |         if (quota.nextRechargeAt == null || !LocalDateTime.now().isBefore(quota.nextRechargeAt)) { | ||||||
|                 quota.remainingFree -= 1 |             quota.remainingFree = FREE_BUCKET | ||||||
|                 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("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.") |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|  |         // 다음 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 |     @Transactional | ||||||
|     fun getStatus(memberId: Long): QuotaStatus { |     fun getStatus(memberId: Long): QuotaStatus { | ||||||
|         return applyRefillOnEnterAndGetStatus(memberId) |         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.CharacterImage | ||||||
| import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService | import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService | ||||||
| import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | 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.ChatMessage | ||||||
| import kr.co.vividnext.sodalive.chat.room.ChatMessageType | import kr.co.vividnext.sodalive.chat.room.ChatMessageType | ||||||
| import kr.co.vividnext.sodalive.chat.room.ChatParticipant | 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 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, |     private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService, | ||||||
|  |     private val chatRoomQuotaService: ChatRoomQuotaService, | ||||||
|  |  | ||||||
|     @Value("\${weraser.api-key}") |     @Value("\${weraser.api-key}") | ||||||
|     private val apiKey: String, |     private val apiKey: String, | ||||||
| @@ -376,8 +378,15 @@ 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 적용 후 상태 반환 |         // 5-1) 글로벌 쿼터 Lazy refill | ||||||
|         val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) |         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 생성 처리 |         // 선택적 캐릭터 이미지 서명 URL 생성 처리 | ||||||
|         // 요구사항: baseRoom이 조건 불만족으로 동일 캐릭터의 내 활성 방으로 라우팅된 경우(bg 이미지 요청 무시)에는 null로 처리 |         // 요구사항: 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( |         return ChatRoomEnterResponse( | ||||||
|             roomId = effectiveRoom.id!!, |             roomId = effectiveRoom.id!!, | ||||||
|             character = characterDto, |             character = characterDto, | ||||||
|             messages = items, |             messages = items, | ||||||
|             hasMoreMessages = hasMore, |             hasMoreMessages = hasMore, | ||||||
|             totalRemaining = quotaStatus.totalRemaining, |             totalRemaining = roomStatus.totalRemaining, | ||||||
|             nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis, |             nextRechargeAtEpoch = nextForEnter, | ||||||
|             bgImageUrl = signedUrl |             bgImageUrl = signedUrl | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| @@ -602,8 +640,13 @@ class ChatRoomService( | |||||||
|         val sessionId = room.sessionId |         val sessionId = room.sessionId | ||||||
|         val characterUUID = character.characterUUID |         val characterUUID = character.characterUUID | ||||||
|  |  | ||||||
|         // 5) 쿼터 확인 및 차감 |         // 5) 쿼터 확인 및 차감 (유료 우선, 무료 사용 시 글로벌과 룸 동시 차감) | ||||||
|         chatQuotaService.consumeOne(member.id!!) |         val roomQuotaAfterConsume = chatRoomQuotaService.consumeOneForSend( | ||||||
|  |             memberId = member.id!!, | ||||||
|  |             chatRoomId = room.id!!, | ||||||
|  |             globalFreeProvider = { chatQuotaService.getStatus(member.id!!).totalRemaining }, | ||||||
|  |             consumeGlobalFree = { chatQuotaService.consumeOneFree(member.id!!) } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         // 6) 외부 API 호출 (최대 3회 재시도) |         // 6) 외부 API 호출 (최대 3회 재시도) | ||||||
|         val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) |         val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) | ||||||
| @@ -646,7 +689,21 @@ class ChatRoomService( | |||||||
|             hasAccess = true |             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) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) |         // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) | ||||||
|         val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) |         val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) | ||||||
| @@ -676,15 +733,15 @@ class ChatRoomService( | |||||||
|             val imageDto = toChatMessageItemDto(imageMsg, member) |             val imageDto = toChatMessageItemDto(imageMsg, member) | ||||||
|             return SendChatMessageResponse( |             return SendChatMessageResponse( | ||||||
|                 messages = listOf(textDto, imageDto), |                 messages = listOf(textDto, imageDto), | ||||||
|                 totalRemaining = status.totalRemaining, |                 totalRemaining = statusTotalRemaining, | ||||||
|                 nextRechargeAtEpoch = status.nextRechargeAtEpochMillis |                 nextRechargeAtEpoch = statusNextRechargeAt | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return SendChatMessageResponse( |         return SendChatMessageResponse( | ||||||
|             messages = listOf(textDto), |             messages = listOf(textDto), | ||||||
|             totalRemaining = status.totalRemaining, |             totalRemaining = statusTotalRemaining, | ||||||
|             nextRechargeAtEpoch = status.nextRechargeAtEpochMillis |             nextRechargeAtEpoch = statusNextRechargeAt | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -846,6 +903,8 @@ class ChatRoomService( | |||||||
|             memberId = member.id!!, |             memberId = member.id!!, | ||||||
|             needCan = 30, |             needCan = 30, | ||||||
|             canUsage = CanUsage.CHAT_ROOM_RESET, |             canUsage = CanUsage.CHAT_ROOM_RESET, | ||||||
|  |             chatRoomId = chatRoomId, | ||||||
|  |             characterId = character.id!!, | ||||||
|             container = container |             container = container | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -855,10 +914,14 @@ class ChatRoomService( | |||||||
|         // 4) 동일한 캐릭터와 새로운 채팅방 생성 |         // 4) 동일한 캐릭터와 새로운 채팅방 생성 | ||||||
|         val created = createOrGetChatRoom(member, character.id!!) |         val created = createOrGetChatRoom(member, character.id!!) | ||||||
|  |  | ||||||
|         // 5) 신규 채팅방 생성 성공 시 무료 채팅 횟수 10으로 설정 |         // 5) 신규 채팅방 생성 성공 시: 기존 방의 유료 쿼터를 새 방으로 이관 | ||||||
|         chatQuotaService.resetFreeToDefault(member.id!!) |         chatRoomQuotaService.transferPaid( | ||||||
|  |             memberId = member.id!!, | ||||||
|         // 6) 생성된 채팅방 데이터 반환 |             fromChatRoomId = chatRoomId, | ||||||
|  |             toChatRoomId = created.chatRoomId, | ||||||
|  |             toCharacterId = character.id!! | ||||||
|  |         ) | ||||||
|  |         // 글로벌 무료 쿼터는 UTC 20:00 기준 lazy 충전이므로 별도의 초기화 불필요 | ||||||
|         return created |         return created | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user