feat(chat): 채팅방 나가기 API 구현

This commit is contained in:
Klaus 2025-08-08 15:48:20 +09:00
parent 830e41dfa3
commit 1509ee0729
4 changed files with 99 additions and 14 deletions

View File

@ -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()

View File

@ -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)
}
} }

View File

@ -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
} }

View File

@ -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)
}
} }