캐릭터 챗봇 #338

Merged
klaus merged 119 commits from test into main 2025-09-10 06:08:47 +00:00
8 changed files with 92 additions and 8 deletions
Showing only changes of commit 42ed4692af - Show all commits

View File

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

View File

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

View File

@ -12,5 +12,6 @@ enum class CanUsage {
AUDITION_VOTE,
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
}

View File

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

View File

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

View File

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

View File

@ -194,3 +194,10 @@ data class SendChatMessageResponse(
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?
)
/**
* 채팅방 초기화 요청 DTO
*/
data class ChatRoomResetRequest(
val container: String
)

View File

@ -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,8 +491,14 @@ class ChatRoomService(
log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message)
}
}
// 최종 실패 로그 (예외 미전파)
log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts)
// 최종 실패 처리
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)
@ -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
}
}