캐릭터 챗봇 #338

Merged
klaus merged 119 commits from test into main 2025-09-10 06:08:47 +00:00
9 changed files with 224 additions and 7 deletions
Showing only changes of commit 6ecac8d331 - Show all commits

View File

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

View File

@ -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("잘못된 요청입니다.")
} }

View File

@ -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 // 채팅 횟수(쿼터) 충전
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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?
) )

View File

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