diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 55b6841..555cc6e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -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!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index c9d2498..ce57b14 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -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("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 4f06828..44bcbd5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -12,5 +12,6 @@ enum class CanUsage { AUDITION_VOTE, CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 - CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 + CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전 + CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt index 80b6ccf..d62c822 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -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 + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt index ff8e9d0..65caa00 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt @@ -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 = mutableListOf() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index aa0f1f1..7434207 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -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) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index b09f230..df80d89 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -194,3 +194,10 @@ data class SendChatMessageResponse( val totalRemaining: Int, val nextRechargeAtEpoch: Long? ) + +/** + * 채팅방 초기화 요청 DTO + */ +data class ChatRoomResetRequest( + val container: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 50bd17b..8458fdb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -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 + } }