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:
2025-09-09 22:42:14 +09:00
parent a9d1b9f4a6
commit fd83abb46c
9 changed files with 470 additions and 75 deletions

View File

@@ -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
}
}