캐릭터 챗봇 #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 | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user