캐릭터 챗봇 #338
|
@ -74,6 +74,7 @@ class CanService(private val repository: CanRepository) {
|
||||||
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
||||||
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||||
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE -> "AI 채팅 개수 구매"
|
||||||
}
|
}
|
||||||
|
|
||||||
val createdAt = it.createdAt!!
|
val createdAt = it.createdAt!!
|
||||||
|
|
|
@ -110,6 +110,9 @@ 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) {
|
||||||
|
// 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속
|
||||||
|
useCan.member = member
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("잘못된 요청입니다.")
|
throw SodaException("잘못된 요청입니다.")
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,5 +11,6 @@ enum class CanUsage {
|
||||||
ALARM_SLOT,
|
ALARM_SLOT,
|
||||||
AUDITION_VOTE,
|
AUDITION_VOTE,
|
||||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||||
CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매
|
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||||
|
CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.quota
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.Id
|
||||||
|
import javax.persistence.Table
|
||||||
|
import javax.persistence.Version
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "chat_quota")
|
||||||
|
class ChatQuota(
|
||||||
|
@Id
|
||||||
|
val memberId: Long,
|
||||||
|
var remainingFree: Int = 10,
|
||||||
|
var remainingPaid: Int = 0,
|
||||||
|
var nextRechargeAt: LocalDateTime? = null,
|
||||||
|
@Version
|
||||||
|
var version: Long? = null
|
||||||
|
) {
|
||||||
|
fun total(): Int = remainingFree + remainingPaid
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.quota
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
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.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/quota")
|
||||||
|
class ChatQuotaController(
|
||||||
|
private val chatQuotaService: ChatQuotaService,
|
||||||
|
private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||||
|
) {
|
||||||
|
|
||||||
|
data class ChatQuotaStatusResponse(
|
||||||
|
val totalRemaining: Int,
|
||||||
|
val nextRechargeAtEpoch: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatQuotaPurchaseRequest(
|
||||||
|
val container: String,
|
||||||
|
val addPaid: Int = 50,
|
||||||
|
val needCan: Int = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
fun getMyQuota(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
): ApiResponse<ChatQuotaStatusResponse> = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
|
|
||||||
|
val s = chatQuotaService.getStatus(member.id!!)
|
||||||
|
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/purchase")
|
||||||
|
fun purchaseQuota(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@RequestBody request: ChatQuotaPurchaseRequest
|
||||||
|
): ApiResponse<Boolean> = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
|
if (request.container.isBlank()) throw SodaException("container를 확인해주세요.")
|
||||||
|
|
||||||
|
// 30캔 차감 처리 (결제 기록 남김)
|
||||||
|
canPaymentService.spendCan(
|
||||||
|
memberId = member.id!!,
|
||||||
|
needCan = if (request.needCan > 0) request.needCan else 30,
|
||||||
|
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||||
|
container = request.container
|
||||||
|
)
|
||||||
|
|
||||||
|
// 유료 횟수 적립 (기본 50)
|
||||||
|
val add = if (request.addPaid > 0) request.addPaid else 50
|
||||||
|
chatQuotaService.purchase(member.id!!, add)
|
||||||
|
ApiResponse.ok(true)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.quota
|
||||||
|
|
||||||
|
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 ChatQuotaRepository : JpaRepository<ChatQuota, Long> {
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select q from ChatQuota q where q.memberId = :memberId")
|
||||||
|
fun findForUpdate(@Param("memberId") memberId: Long): ChatQuota?
|
||||||
|
|
||||||
|
fun findByMemberId(memberId: Long): ChatQuota?
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
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.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ChatQuotaService(
|
||||||
|
private val repo: ChatQuotaRepository
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val FREE_BUCKET = 10
|
||||||
|
private const val RECHARGE_HOURS = 6L
|
||||||
|
}
|
||||||
|
|
||||||
|
data class QuotaStatus(
|
||||||
|
val totalRemaining: Int,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun consumeOne(memberId: Long) {
|
||||||
|
val now = LocalDateTime.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.plusHours(RECHARGE_HOURS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quota.remainingPaid > 0 -> {
|
||||||
|
quota.remainingPaid -= 1
|
||||||
|
if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) {
|
||||||
|
quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (quota.nextRechargeAt == null) {
|
||||||
|
quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS)
|
||||||
|
}
|
||||||
|
throw SodaException("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getStatus(memberId: Long): QuotaStatus {
|
||||||
|
val q = repo.findByMemberId(memberId) ?: return QuotaStatus(
|
||||||
|
totalRemaining = FREE_BUCKET,
|
||||||
|
nextRechargeAtEpochMillis = null
|
||||||
|
)
|
||||||
|
val total = q.remainingFree + q.remainingPaid
|
||||||
|
val epoch = q.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
|
return QuotaStatus(totalRemaining = total, nextRechargeAtEpochMillis = epoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun purchase(memberId: Long, addPaid: Int) {
|
||||||
|
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
|
||||||
|
quota.remainingPaid += addPaid
|
||||||
|
}
|
||||||
|
}
|
|
@ -180,5 +180,16 @@ data class ChatRoomEnterResponse(
|
||||||
val roomId: Long,
|
val roomId: Long,
|
||||||
val character: ChatRoomEnterCharacterDto,
|
val character: ChatRoomEnterCharacterDto,
|
||||||
val messages: List<ChatMessageItemDto>,
|
val messages: List<ChatMessageItemDto>,
|
||||||
val hasMoreMessages: Boolean
|
val hasMoreMessages: Boolean,
|
||||||
|
val totalRemaining: Int,
|
||||||
|
val nextRechargeAtEpoch: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채팅 메시지 전송 응답 DTO (메시지 + 쿼터 상태)
|
||||||
|
*/
|
||||||
|
data class SendChatMessageResponse(
|
||||||
|
val messages: List<ChatMessageItemDto>,
|
||||||
|
val totalRemaining: Int,
|
||||||
|
val nextRechargeAtEpoch: Long?
|
||||||
)
|
)
|
||||||
|
|
|
@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
|
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
|
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
|
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
||||||
|
@ -49,6 +50,7 @@ class ChatRoomService(
|
||||||
private val characterImageService: CharacterImageService,
|
private val characterImageService: CharacterImageService,
|
||||||
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,
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
|
@ -335,11 +337,16 @@ 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 적용 후 상태 반환
|
||||||
|
val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!)
|
||||||
|
|
||||||
return ChatRoomEnterResponse(
|
return ChatRoomEnterResponse(
|
||||||
roomId = room.id!!,
|
roomId = room.id!!,
|
||||||
character = characterDto,
|
character = characterDto,
|
||||||
messages = items,
|
messages = items,
|
||||||
hasMoreMessages = hasMore
|
hasMoreMessages = hasMore,
|
||||||
|
totalRemaining = quotaStatus.totalRemaining,
|
||||||
|
nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -490,7 +497,7 @@ class ChatRoomService(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun sendMessage(member: Member, chatRoomId: Long, message: String): List<ChatMessageItemDto> {
|
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
|
||||||
// 1) 방 존재 확인
|
// 1) 방 존재 확인
|
||||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||||
SodaException("채팅방을 찾을 수 없습니다.")
|
SodaException("채팅방을 찾을 수 없습니다.")
|
||||||
|
@ -512,7 +519,10 @@ class ChatRoomService(
|
||||||
val sessionId = room.sessionId
|
val sessionId = room.sessionId
|
||||||
val characterUUID = character.characterUUID
|
val characterUUID = character.characterUUID
|
||||||
|
|
||||||
// 5) 외부 API 호출 (최대 3회 재시도)
|
// 5) 쿼터 확인 및 차감
|
||||||
|
chatQuotaService.consumeOne(member.id!!)
|
||||||
|
|
||||||
|
// 6) 외부 API 호출 (최대 3회 재시도)
|
||||||
val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
|
val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
|
||||||
|
|
||||||
// 6) 내 메시지 저장
|
// 6) 내 메시지 저장
|
||||||
|
@ -553,6 +563,8 @@ class ChatRoomService(
|
||||||
hasAccess = true
|
hasAccess = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val status = chatQuotaService.getStatus(member.id!!)
|
||||||
|
|
||||||
// 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우)
|
// 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우)
|
||||||
val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply)
|
val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply)
|
||||||
if (matchedImage != null) {
|
if (matchedImage != null) {
|
||||||
|
@ -579,10 +591,18 @@ class ChatRoomService(
|
||||||
)
|
)
|
||||||
|
|
||||||
val imageDto = toChatMessageItemDto(imageMsg, member)
|
val imageDto = toChatMessageItemDto(imageMsg, member)
|
||||||
return listOf(textDto, imageDto)
|
return SendChatMessageResponse(
|
||||||
|
messages = listOf(textDto, imageDto),
|
||||||
|
totalRemaining = status.totalRemaining,
|
||||||
|
nextRechargeAtEpoch = status.nextRechargeAtEpochMillis
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return listOf(textDto)
|
return SendChatMessageResponse(
|
||||||
|
messages = listOf(textDto),
|
||||||
|
totalRemaining = status.totalRemaining,
|
||||||
|
nextRechargeAtEpoch = status.nextRechargeAtEpochMillis
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toChatMessageItemDto(
|
private fun toChatMessageItemDto(
|
||||||
|
|
Loading…
Reference in New Issue