캐릭터 챗봇 #338
|
@ -75,6 +75,7 @@ class CanService(private val repository: CanRepository) {
|
|||
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
|
||||
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
|
||||
}
|
||||
|
||||
val createdAt = it.createdAt!!
|
||||
|
|
|
@ -113,6 +113,9 @@ class CanPaymentService(
|
|||
} else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) {
|
||||
// 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속
|
||||
useCan.member = member
|
||||
} else if (canUsage == CanUsage.CHAT_ROOM_RESET) {
|
||||
// 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속
|
||||
useCan.member = member
|
||||
} else {
|
||||
throw SodaException("잘못된 요청입니다.")
|
||||
}
|
||||
|
|
|
@ -12,5 +12,6 @@ enum class CanUsage {
|
|||
AUDITION_VOTE,
|
||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||
CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전
|
||||
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||
}
|
||||
|
|
|
@ -72,4 +72,11 @@ class ChatQuotaService(
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import javax.persistence.OneToMany
|
|||
class ChatRoom(
|
||||
val sessionId: String,
|
||||
val title: String,
|
||||
val isActive: Boolean = true
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||
val messages: MutableList<ChatMessage> = mutableListOf()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package kr.co.vividnext.sodalive.chat.room.controller
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagePurchaseRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomResetRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
|
@ -181,4 +182,23 @@ class ChatRoomController(
|
|||
val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container)
|
||||
ApiResponse.ok(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅방 초기화 API
|
||||
* - 로그인 및 본인인증 확인
|
||||
* - 내가 참여 중인 AI 캐릭터 채팅방인지 확인
|
||||
* - 30캔 결제 → 현재 채팅방 나가기 → 동일 캐릭터와 새 채팅방 생성 → 생성된 채팅방 데이터 반환
|
||||
*/
|
||||
@PostMapping("/{chatRoomId}/reset")
|
||||
fun resetChatRoom(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable chatRoomId: Long,
|
||||
@RequestBody request: ChatRoomResetRequest
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -194,3 +194,10 @@ data class SendChatMessageResponse(
|
|||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpoch: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 채팅방 초기화 요청 DTO
|
||||
*/
|
||||
data class ChatRoomResetRequest(
|
||||
val container: String
|
||||
)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package kr.co.vividnext.sodalive.chat.room.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
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
|
||||
|
@ -422,7 +423,7 @@ class ChatRoomService(
|
|||
}
|
||||
|
||||
@Transactional
|
||||
fun leaveChatRoom(member: Member, chatRoomId: Long) {
|
||||
fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) {
|
||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
SodaException("채팅방을 찾을 수 없습니다.")
|
||||
}
|
||||
|
@ -441,12 +442,13 @@ class ChatRoomService(
|
|||
|
||||
// 3) 내가 마지막 USER였다면 외부 세션 종료
|
||||
if (userCount == 0L) {
|
||||
endExternalSession(room.sessionId)
|
||||
endExternalSession(room.sessionId, throwOnFailure = throwOnSessionEndFailure)
|
||||
room.isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun endExternalSession(sessionId: String) {
|
||||
// 사용자 흐름을 방해하지 않기 위해 실패 시 예외를 던지지 않고 내부 재시도 후 로그만 남깁니다.
|
||||
private fun endExternalSession(sessionId: String, throwOnFailure: Boolean = false) {
|
||||
// 기본 동작: 내부 재시도. throwOnFailure=true일 때는 최종 실패 시 예외 전파.
|
||||
val maxAttempts = 3
|
||||
var attempt = 0
|
||||
while (attempt < maxAttempts) {
|
||||
|
@ -489,9 +491,15 @@ class ChatRoomService(
|
|||
log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message)
|
||||
}
|
||||
}
|
||||
// 최종 실패 로그 (예외 미전파)
|
||||
// 최종 실패 처리
|
||||
val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요."
|
||||
if (throwOnFailure) {
|
||||
log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts)
|
||||
throw SodaException(message)
|
||||
} else {
|
||||
log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse {
|
||||
|
@ -774,4 +782,41 @@ class ChatRoomService(
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse {
|
||||
// 0) 방 존재 및 내 참여 여부 확인
|
||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
SodaException("채팅방을 찾을 수 없습니다.")
|
||||
}
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
// 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인)
|
||||
val characterParticipant = participantRepository
|
||||
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
val character = characterParticipant.character
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
|
||||
// 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용)
|
||||
canPaymentService.spendCan(
|
||||
memberId = member.id!!,
|
||||
needCan = 30,
|
||||
canUsage = CanUsage.CHAT_ROOM_RESET,
|
||||
container = container
|
||||
)
|
||||
|
||||
// 3) 현재 채팅방 나가기 (세션 종료 실패 시 롤백되도록 설정)
|
||||
leaveChatRoom(member, chatRoomId, true)
|
||||
|
||||
// 4) 동일한 캐릭터와 새로운 채팅방 생성
|
||||
val created = createOrGetChatRoom(member, character.id!!)
|
||||
|
||||
// 5) 신규 채팅방 생성 성공 시 무료 채팅 횟수 10으로 설정
|
||||
chatQuotaService.resetFreeToDefault(member.id!!)
|
||||
|
||||
// 6) 생성된 채팅방 데이터 반환
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue