feat(chat): 채팅방 초기화 API 추가 및 세션 종료 실패 시 롤백 처리
- /api/chat/room/{chatRoomId}/reset POST 엔드포인트 추가
- 초기화 절차: 30캔 결제 → 기존 방 나가기 → 동일 캐릭터로 새 방 생성 → 응답 반환
- 결제 시 CanUsage.CHAT_ROOM_RESET 신규 항목 사용(본인 귀속)
- ChatQuotaService.resetFreeToDefault 추가 및 초기화 성공 시 무료 10회로 리셋(nextRechargeAt 초기화)
- 사용내역 타이틀에 "캐릭터 톡 초기화" 노출(CanService)
- ChatRoomResetRequest DTO(container 포함) 추가
- leaveChatRoom에 throwOnSessionEndFailure 옵션 추가(기본 false 유지)
- endExternalSession에 throwOnFailure 옵션 추가: 최대 3회 재시도 후 실패 시 예외 전파 가능
- 채팅방 초기화 흐름에서는 외부 세션 종료 실패 시 예외를 던져 트랜잭션 롤백되도록 처리
			
			
This commit is contained in:
		| @@ -75,6 +75,7 @@ class CanService(private val repository: CanRepository) { | |||||||
|                     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 -> "캐릭터 톡 이용권 구매" |                     CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" | ||||||
|  |                     CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화" | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 val createdAt = it.createdAt!! |                 val createdAt = it.createdAt!! | ||||||
|   | |||||||
| @@ -113,6 +113,9 @@ class CanPaymentService( | |||||||
|         } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { |         } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { | ||||||
|             // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 |             // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 | ||||||
|             useCan.member = member |             useCan.member = member | ||||||
|  |         } else if (canUsage == CanUsage.CHAT_ROOM_RESET) { | ||||||
|  |             // 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속 | ||||||
|  |             useCan.member = member | ||||||
|         } else { |         } else { | ||||||
|             throw SodaException("잘못된 요청입니다.") |             throw SodaException("잘못된 요청입니다.") | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -12,5 +12,6 @@ enum class CanUsage { | |||||||
|     AUDITION_VOTE, |     AUDITION_VOTE, | ||||||
|     CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) |     CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) | ||||||
|     CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 |     CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 | ||||||
|     CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 |     CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전 | ||||||
|  |     CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -72,4 +72,11 @@ class ChatQuotaService( | |||||||
|         quota.remainingPaid += addPaid |         quota.remainingPaid += addPaid | ||||||
|         quota.nextRechargeAt = null |         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( | class ChatRoom( | ||||||
|     val sessionId: String, |     val sessionId: String, | ||||||
|     val title: String, |     val title: String, | ||||||
|     val isActive: Boolean = true |     var isActive: Boolean = true | ||||||
| ) : BaseEntity() { | ) : BaseEntity() { | ||||||
|     @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) |     @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) | ||||||
|     val messages: MutableList<ChatMessage> = mutableListOf() |     val messages: MutableList<ChatMessage> = mutableListOf() | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.room.controller | 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.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.CreateChatRoomRequest | ||||||
| import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest | import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest | ||||||
| import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService | import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService | ||||||
| @@ -181,4 +182,23 @@ class ChatRoomController( | |||||||
|         val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) |         val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) | ||||||
|         ApiResponse.ok(result) |         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 totalRemaining: Int, | ||||||
|     val nextRechargeAtEpoch: Long? |     val nextRechargeAtEpoch: Long? | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 채팅방 초기화 요청 DTO | ||||||
|  |  */ | ||||||
|  | data class ChatRoomResetRequest( | ||||||
|  |     val container: String | ||||||
|  | ) | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.room.service | package kr.co.vividnext.sodalive.chat.room.service | ||||||
|  |  | ||||||
| import com.fasterxml.jackson.databind.ObjectMapper | 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.CharacterImage | ||||||
| import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService | import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService | ||||||
| import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | ||||||
| @@ -422,7 +423,7 @@ class ChatRoomService( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     @Transactional |     @Transactional | ||||||
|     fun leaveChatRoom(member: Member, chatRoomId: Long) { |     fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) { | ||||||
|         val room = chatRoomRepository.findById(chatRoomId).orElseThrow { |         val room = chatRoomRepository.findById(chatRoomId).orElseThrow { | ||||||
|             SodaException("채팅방을 찾을 수 없습니다.") |             SodaException("채팅방을 찾을 수 없습니다.") | ||||||
|         } |         } | ||||||
| @@ -441,12 +442,13 @@ class ChatRoomService( | |||||||
|  |  | ||||||
|         // 3) 내가 마지막 USER였다면 외부 세션 종료 |         // 3) 내가 마지막 USER였다면 외부 세션 종료 | ||||||
|         if (userCount == 0L) { |         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 |         val maxAttempts = 3 | ||||||
|         var attempt = 0 |         var attempt = 0 | ||||||
|         while (attempt < maxAttempts) { |         while (attempt < maxAttempts) { | ||||||
| @@ -489,9 +491,15 @@ class ChatRoomService( | |||||||
|                 log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message) |                 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) |             log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) | ||||||
|         } |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @Transactional(readOnly = true) |     @Transactional(readOnly = true) | ||||||
|     fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { |     fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { | ||||||
| @@ -774,4 +782,41 @@ class ChatRoomService( | |||||||
|         } |         } | ||||||
|         return null |         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