From 1bafbed17cf2ecd4f25af45c5b39436161fd1240 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 00:27:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅방 생성 및 조회 기능 구현 - 외부 API 연동을 통한 세션 생성 로직 추가 - 채팅방 참여자(유저, 캐릭터) 추가 기능 구현 - UUID 기반 유저 ID 생성 로직 추가 --- .../room/controller/ChatRoomController.kt | 45 +++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 46 +++++ .../CharacterChatParticipantRepository.kt | 36 ++++ .../repository/CharacterChatRoomRepository.kt | 35 ++++ .../chat/room/service/ChatRoomService.kt | 167 ++++++++++++++++++ src/main/resources/application.yml | 1 + 6 files changed, 330 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt 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 new file mode 100644 index 0000000..e595c83 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.chat.room.controller + +import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest +import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/chat/room") +class ChatRoomController( + private val chatRoomService: ChatRoomService +) { + + /** + * 채팅방 생성 API + * + * 1. 캐릭터 ID, 유저 ID가 참여 중인 채팅방이 있는지 확인 + * 2. 있으면 채팅방 ID 반환 + * 3. 없으면 외부 API 호출 + * 4. 성공시 외부 API에서 가져오는 sessionId를 포함하여 채팅방 생성 + * 5. 채팅방 참여자로 캐릭터와 유저 추가 + * 6. 채팅방 ID 반환 + * + * @param member 인증된 사용자 + * @param request 채팅방 생성 요청 DTO + * @return 채팅방 ID + */ + @PostMapping("/create") + fun createChatRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestBody request: CreateChatRoomRequest + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.createOrGetChatRoom(member, request.characterId) + 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 new file mode 100644 index 0000000..2b53285 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.chat.room.dto + +/** + * 채팅방 생성 요청 DTO + */ +data class CreateChatRoomRequest( + val characterId: Long +) + +/** + * 채팅방 생성 응답 DTO + */ +data class CreateChatRoomResponse( + val chatRoomId: Long +) + +/** + * 외부 API 채팅 세션 응답 DTO + */ +data class ExternalChatSessionResponse( + val success: Boolean, + val message: String?, + val data: ExternalChatSessionData? +) + +/** + * 외부 API 채팅 세션 데이터 DTO + */ +data class ExternalChatSessionData( + val sessionId: String, + val userId: String, + val characterId: String, + val character: ExternalCharacterData, + val status: String, + val createdAt: String +) + +/** + * 외부 API 캐릭터 데이터 DTO + */ +data class ExternalCharacterData( + val id: String, + val name: String, + val age: String, + val gender: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt new file mode 100644 index 0000000..77b2a5b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt @@ -0,0 +1,36 @@ +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.member.Member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CharacterChatParticipantRepository : JpaRepository { + + /** + * 특정 채팅방에 참여 중인 멤버 참여자 찾기 + * + * @param chatRoom 채팅방 + * @param member 멤버 + * @return 채팅방 참여자 (없으면 null) + */ + fun findByChatRoomAndMemberAndIsActiveTrue( + chatRoom: CharacterChatRoom, + member: Member + ): CharacterChatParticipant? + + /** + * 특정 채팅방에 참여 중인 캐릭터 참여자 찾기 + * + * @param chatRoom 채팅방 + * @param character 캐릭터 + * @return 채팅방 참여자 (없으면 null) + */ + fun findByChatRoomAndCharacterAndIsActiveTrue( + chatRoom: CharacterChatRoom, + character: ChatCharacter + ): CharacterChatParticipant? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt new file mode 100644 index 0000000..469fc46 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.chat.room.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface CharacterChatRoomRepository : JpaRepository { + + /** + * 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리 + * + * @param member 멤버 + * @param character 캐릭터 + * @return 활성화된 채팅방 (없으면 null) + */ + @Query( + """ + SELECT DISTINCT r FROM CharacterChatRoom r + JOIN r.participants p1 + JOIN r.participants p2 + WHERE p1.member = :member AND p1.isActive = true + AND p2.character = :character AND p2.isActive = true + AND r.isActive = true + """ + ) + fun findActiveChatRoomByMemberAndCharacter( + @Param("member") member: Member, + @Param("character") character: ChatCharacter + ): CharacterChatRoom? +} 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 new file mode 100644 index 0000000..4a8f47f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -0,0 +1,167 @@ +package kr.co.vividnext.sodalive.chat.room.service + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +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.chat.room.dto.CreateChatRoomResponse +import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionResponse +import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository +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.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.client.RestTemplate +import java.util.UUID + +@Service +class ChatRoomService( + private val chatRoomRepository: CharacterChatRoomRepository, + private val participantRepository: CharacterChatParticipantRepository, + private val characterService: ChatCharacterService, + + @Value("\${weraser.api-key}") + private val apiKey: String, + + @Value("\${weraser.api-url}") + private val apiUrl: String, + + @Value("\${server.env}") + private val serverEnv: String +) { + + /** + * 채팅방 생성 또는 조회 + * + * @param member 멤버 + * @param characterId 캐릭터 ID + * @return 채팅방 ID + */ + @Transactional + fun createOrGetChatRoom(member: Member, characterId: Long): CreateChatRoomResponse { + // 1. 캐릭터 조회 + val character = characterService.findById(characterId) + ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") + + // 2. 이미 참여 중인 채팅방이 있는지 확인 + val existingChatRoom = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, character) + + // 3. 있으면 채팅방 ID 반환 + if (existingChatRoom != null) { + return CreateChatRoomResponse(chatRoomId = existingChatRoom.id!!) + } + + // 4. 없으면 외부 API 호출하여 세션 생성 + val userId = generateUserId(member.id!!) + val sessionId = callExternalApiForChatSession(userId, character.characterUUID) + + // 5. 채팅방 생성 + val chatRoom = CharacterChatRoom( + sessionId = sessionId, + title = character.name, + isActive = true + ) + val savedChatRoom = chatRoomRepository.save(chatRoom) + + // 6. 채팅방 참여자 추가 (멤버) + val memberParticipant = CharacterChatParticipant( + chatRoom = savedChatRoom, + participantType = ParticipantType.USER, + member = member, + character = null, + isActive = true + ) + participantRepository.save(memberParticipant) + + // 7. 채팅방 참여자 추가 (캐릭터) + val characterParticipant = CharacterChatParticipant( + chatRoom = savedChatRoom, + participantType = ParticipantType.CHARACTER, + member = null, + character = character, + isActive = true + ) + participantRepository.save(characterParticipant) + + // 8. 채팅방 ID 반환 + return CreateChatRoomResponse(chatRoomId = savedChatRoom.id!!) + } + + /** + * 유저 ID 생성 + * "$serverEnv_user_$유저번호"를 UUID로 변환 + * + * @param memberId 멤버 ID + * @return UUID 형태의 유저 ID + */ + private fun generateUserId(memberId: Long): String { + val userIdString = "${serverEnv}_user_$memberId" + return UUID.nameUUIDFromBytes(userIdString.toByteArray()).toString() + } + + /** + * 외부 API 호출하여 채팅 세션 생성 + * + * @param userId 유저 ID (UUID) + * @param characterUUID 캐릭터 UUID + * @return 세션 ID + */ + private fun callExternalApiForChatSession(userId: String, characterUUID: String): String { + try { + val factory = SimpleClientHttpRequestFactory() + factory.setConnectTimeout(20000) // 20초 + factory.setReadTimeout(20000) // 20초 + + val restTemplate = RestTemplate(factory) + + val headers = HttpHeaders() + headers.set("x-api-key", apiKey) + headers.contentType = MediaType.APPLICATION_JSON + + // 요청 바디 생성 - userId와 characterId 전달 + val requestBody = mapOf( + "userId" to userId, + "characterId" to characterUUID + ) + + val httpEntity = HttpEntity(requestBody, headers) + + val response = restTemplate.exchange( + "$apiUrl/api/session", + HttpMethod.POST, + httpEntity, + String::class.java + ) + + // 응답 파싱 + val objectMapper = ObjectMapper() + val apiResponse = objectMapper.readValue(response.body, ExternalChatSessionResponse::class.java) + + // success가 false이면 throw + if (!apiResponse.success) { + throw SodaException(apiResponse.message ?: "채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + } + + // success가 true이면 파라미터로 넘긴 값과 일치하는지 확인 + val data = apiResponse.data ?: throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + + if (data.userId != userId && data.characterId != characterUUID && data.status != "active") { + throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + } + + // 세션 ID 반환 + return data.sessionId + } catch (e: Exception) { + e.printStackTrace() + throw SodaException("${e.message}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5620001..aa37c6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,6 @@ server: shutdown: graceful + env: ${SERVER_ENV} logging: level: