캐릭터 챗봇 #338
| @@ -29,7 +29,7 @@ class CharacterChatParticipant( | ||||
|     @JoinColumn(name = "character_id") | ||||
|     val character: ChatCharacter? = null, | ||||
|  | ||||
|     val isActive: Boolean = true | ||||
|     var isActive: Boolean = true | ||||
| ) : BaseEntity() { | ||||
|     @OneToMany(mappedBy = "participant", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) | ||||
|     val messages: MutableList<CharacterChatMessage> = mutableListOf() | ||||
|   | ||||
| @@ -78,4 +78,23 @@ class ChatRoomController( | ||||
|         val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) | ||||
|         ApiResponse.ok(isActive) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 채팅방 나가기 API | ||||
|      * - URL에 chatRoomId 포함 | ||||
|      * - 내가 참여 중인지 확인 (아니면 "잘못된 접근입니다") | ||||
|      * - 내 참여자 isActive=false 처리 | ||||
|      * - 내가 마지막 USER였다면 외부 API로 세션 종료 호출 | ||||
|      */ | ||||
|     @PostMapping("/{chatRoomId}/leave") | ||||
|     fun leaveChatRoom( | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, | ||||
|         @PathVariable chatRoomId: Long | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||
|  | ||||
|         chatRoomService.leaveChatRoom(member, chatRoomId) | ||||
|         ApiResponse.ok(true) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| package kr.co.vividnext.sodalive.chat.room.repository | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant | ||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | ||||
| import kr.co.vividnext.sodalive.chat.room.ParticipantType | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.stereotype.Repository | ||||
| @@ -12,10 +12,6 @@ interface CharacterChatParticipantRepository : JpaRepository<CharacterChatPartic | ||||
|  | ||||
|     /** | ||||
|      * 특정 채팅방에 참여 중인 멤버 참여자 찾기 | ||||
|      * | ||||
|      * @param chatRoom 채팅방 | ||||
|      * @param member 멤버 | ||||
|      * @return 채팅방 참여자 (없으면 null) | ||||
|      */ | ||||
|     fun findByChatRoomAndMemberAndIsActiveTrue( | ||||
|         chatRoom: CharacterChatRoom, | ||||
| @@ -23,14 +19,10 @@ interface CharacterChatParticipantRepository : JpaRepository<CharacterChatPartic | ||||
|     ): CharacterChatParticipant? | ||||
|  | ||||
|     /** | ||||
|      * 특정 채팅방에 참여 중인 캐릭터 참여자 찾기 | ||||
|      * | ||||
|      * @param chatRoom 채팅방 | ||||
|      * @param character 캐릭터 | ||||
|      * @return 채팅방 참여자 (없으면 null) | ||||
|      * 특정 채팅방의 활성 USER 참여자 수 | ||||
|      */ | ||||
|     fun findByChatRoomAndCharacterAndIsActiveTrue( | ||||
|     fun countByChatRoomAndParticipantTypeAndIsActiveTrue( | ||||
|         chatRoom: CharacterChatRoom, | ||||
|         character: ChatCharacter | ||||
|     ): CharacterChatParticipant? | ||||
|         participantType: ParticipantType | ||||
|     ): Long | ||||
| } | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRep | ||||
| import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import org.slf4j.LoggerFactory | ||||
| import org.springframework.beans.factory.annotation.Value | ||||
| import org.springframework.http.HttpEntity | ||||
| import org.springframework.http.HttpHeaders | ||||
| @@ -45,6 +46,7 @@ class ChatRoomService( | ||||
|     @Value("\${cloud.aws.cloud-front.host}") | ||||
|     private val imageHost: String | ||||
| ) { | ||||
|     private val log = LoggerFactory.getLogger(ChatRoomService::class.java) | ||||
|  | ||||
|     /** | ||||
|      * 채팅방 생성 또는 조회 | ||||
| @@ -250,4 +252,76 @@ class ChatRoomService( | ||||
|             throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun leaveChatRoom(member: Member, chatRoomId: Long) { | ||||
|         val room = chatRoomRepository.findById(chatRoomId).orElseThrow { | ||||
|             SodaException("채팅방을 찾을 수 없습니다.") | ||||
|         } | ||||
|         val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) | ||||
|             ?: throw SodaException("잘못된 접근입니다") | ||||
|  | ||||
|         // 1) 나가기 처리 | ||||
|         participant.isActive = false | ||||
|         participantRepository.save(participant) | ||||
|  | ||||
|         // 2) 남은 USER 참여자 수 확인 | ||||
|         val userCount = participantRepository.countByChatRoomAndParticipantTypeAndIsActiveTrue( | ||||
|             room, | ||||
|             ParticipantType.USER | ||||
|         ) | ||||
|  | ||||
|         // 3) 내가 마지막 USER였다면 외부 세션 종료 | ||||
|         if (userCount == 0L) { | ||||
|             endExternalSession(room.sessionId) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun endExternalSession(sessionId: String) { | ||||
|         // 사용자 흐름을 방해하지 않기 위해 실패 시 예외를 던지지 않고 내부 재시도 후 로그만 남깁니다. | ||||
|         val maxAttempts = 3 | ||||
|         var attempt = 0 | ||||
|         while (attempt < maxAttempts) { | ||||
|             attempt++ | ||||
|             try { | ||||
|                 val factory = SimpleClientHttpRequestFactory() | ||||
|                 factory.setConnectTimeout(20000) | ||||
|                 factory.setReadTimeout(20000) | ||||
|  | ||||
|                 val restTemplate = RestTemplate(factory) | ||||
|  | ||||
|                 val headers = HttpHeaders() | ||||
|                 headers.set("x-api-key", apiKey) | ||||
|                 headers.contentType = MediaType.APPLICATION_JSON | ||||
|  | ||||
|                 val httpEntity = HttpEntity(null, headers) | ||||
|  | ||||
|                 val response = restTemplate.exchange( | ||||
|                     "$apiUrl/api/session/$sessionId/end", | ||||
|                     HttpMethod.PUT, | ||||
|                     httpEntity, | ||||
|                     String::class.java | ||||
|                 ) | ||||
|  | ||||
|                 val objectMapper = ObjectMapper() | ||||
|                 val node = objectMapper.readTree(response.body) | ||||
|                 val success = node.get("success")?.asBoolean(false) ?: false | ||||
|                 if (success) { | ||||
|                     log.info("[chat] 외부 세션 종료 성공: sessionId={}, attempt={}", sessionId, attempt) | ||||
|                     return | ||||
|                 } else { | ||||
|                     log.warn( | ||||
|                         "[chat] 외부 세션 종료 응답 실패: sessionId={}, attempt={}, body={}", | ||||
|                         sessionId, | ||||
|                         attempt, | ||||
|                         response.body | ||||
|                     ) | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|                 log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message) | ||||
|             } | ||||
|         } | ||||
|         // 최종 실패 로그 (예외 미전파) | ||||
|         log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user