캐릭터 챗봇 #338
|
@ -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?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
@Transactional
|
||||||
fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus {
|
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))
|
||||||
if (quota.remainingFree == 0 && quota.nextRechargeAt != null && !now.isBefore(quota.nextRechargeAt)) {
|
// Lazy refill: nextRechargeAt이 없거나 현재를 지났다면 무료 40 회복
|
||||||
|
val nextRecharge = nextUtc20LocalDateTime(now)
|
||||||
|
if (quota.nextRechargeAt == null || !LocalDateTime.now().isBefore(quota.nextRechargeAt)) {
|
||||||
quota.remainingFree = FREE_BUCKET
|
quota.remainingFree = FREE_BUCKET
|
||||||
quota.nextRechargeAt = null
|
|
||||||
}
|
}
|
||||||
|
// 다음 UTC20 기준 시간으로 항상 갱신
|
||||||
|
quota.nextRechargeAt = nextRecharge
|
||||||
|
|
||||||
val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
return QuotaStatus(totalRemaining = quota.total(), nextRechargeAtEpochMillis = epoch)
|
// 글로벌은 유료 개념 제거: totalRemaining은 remainingFree만 사용
|
||||||
|
return QuotaStatus(totalRemaining = quota.remainingFree, nextRechargeAtEpochMillis = epoch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun consumeOne(memberId: Long) {
|
fun consumeOneFree(memberId: Long) {
|
||||||
val now = LocalDateTime.now()
|
|
||||||
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
|
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
|
||||||
|
if (quota.remainingFree <= 0) {
|
||||||
when {
|
// 소비 불가: 호출자는 상태 조회로 남은 시간을 판단
|
||||||
quota.remainingFree > 0 -> {
|
throw IllegalStateException("No global free quota")
|
||||||
|
}
|
||||||
quota.remainingFree -= 1
|
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("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue