캐릭터 챗봇 #338
| @@ -29,7 +29,7 @@ class CharacterChatParticipant( | |||||||
|     @JoinColumn(name = "character_id") |     @JoinColumn(name = "character_id") | ||||||
|     val character: ChatCharacter? = null, |     val character: ChatCharacter? = null, | ||||||
|  |  | ||||||
|     val isActive: Boolean = true |     var isActive: Boolean = true | ||||||
| ) : BaseEntity() { | ) : BaseEntity() { | ||||||
|     @OneToMany(mappedBy = "participant", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) |     @OneToMany(mappedBy = "participant", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) | ||||||
|     val messages: MutableList<CharacterChatMessage> = mutableListOf() |     val messages: MutableList<CharacterChatMessage> = mutableListOf() | ||||||
|   | |||||||
| @@ -78,4 +78,23 @@ class ChatRoomController( | |||||||
|         val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) |         val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) | ||||||
|         ApiResponse.ok(isActive) |         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 | 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.CharacterChatParticipant | ||||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | ||||||
|  | import kr.co.vividnext.sodalive.chat.room.ParticipantType | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
| import org.springframework.data.jpa.repository.JpaRepository | import org.springframework.data.jpa.repository.JpaRepository | ||||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||||
| @@ -12,10 +12,6 @@ interface CharacterChatParticipantRepository : JpaRepository<CharacterChatPartic | |||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 특정 채팅방에 참여 중인 멤버 참여자 찾기 |      * 특정 채팅방에 참여 중인 멤버 참여자 찾기 | ||||||
|      * |  | ||||||
|      * @param chatRoom 채팅방 |  | ||||||
|      * @param member 멤버 |  | ||||||
|      * @return 채팅방 참여자 (없으면 null) |  | ||||||
|      */ |      */ | ||||||
|     fun findByChatRoomAndMemberAndIsActiveTrue( |     fun findByChatRoomAndMemberAndIsActiveTrue( | ||||||
|         chatRoom: CharacterChatRoom, |         chatRoom: CharacterChatRoom, | ||||||
| @@ -23,14 +19,10 @@ interface CharacterChatParticipantRepository : JpaRepository<CharacterChatPartic | |||||||
|     ): CharacterChatParticipant? |     ): CharacterChatParticipant? | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 특정 채팅방에 참여 중인 캐릭터 참여자 찾기 |      * 특정 채팅방의 활성 USER 참여자 수 | ||||||
|      * |  | ||||||
|      * @param chatRoom 채팅방 |  | ||||||
|      * @param character 캐릭터 |  | ||||||
|      * @return 채팅방 참여자 (없으면 null) |  | ||||||
|      */ |      */ | ||||||
|     fun findByChatRoomAndCharacterAndIsActiveTrue( |     fun countByChatRoomAndParticipantTypeAndIsActiveTrue( | ||||||
|         chatRoom: CharacterChatRoom, |         chatRoom: CharacterChatRoom, | ||||||
|         character: ChatCharacter |         participantType: ParticipantType | ||||||
|     ): CharacterChatParticipant? |     ): 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.chat.room.repository.CharacterChatRoomRepository | ||||||
| import kr.co.vividnext.sodalive.common.SodaException | import kr.co.vividnext.sodalive.common.SodaException | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
|  | import org.slf4j.LoggerFactory | ||||||
| import org.springframework.beans.factory.annotation.Value | import org.springframework.beans.factory.annotation.Value | ||||||
| import org.springframework.http.HttpEntity | import org.springframework.http.HttpEntity | ||||||
| import org.springframework.http.HttpHeaders | import org.springframework.http.HttpHeaders | ||||||
| @@ -45,6 +46,7 @@ class ChatRoomService( | |||||||
|     @Value("\${cloud.aws.cloud-front.host}") |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|     private val imageHost: String |     private val imageHost: String | ||||||
| ) { | ) { | ||||||
|  |     private val log = LoggerFactory.getLogger(ChatRoomService::class.java) | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 채팅방 생성 또는 조회 |      * 채팅방 생성 또는 조회 | ||||||
| @@ -250,4 +252,76 @@ class ChatRoomService( | |||||||
|             throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") |             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