feat(chat): 채팅방 생성 API 구현

- 채팅방 생성 및 조회 기능 구현
- 외부 API 연동을 통한 세션 생성 로직 추가
- 채팅방 참여자(유저, 캐릭터) 추가 기능 구현
- UUID 기반 유저 ID 생성 로직 추가
This commit is contained in:
Klaus 2025-08-08 00:27:25 +09:00
parent 694d9cd05a
commit 1bafbed17c
6 changed files with 330 additions and 0 deletions

View File

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

View File

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

View File

@ -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<CharacterChatParticipant, Long> {
/**
* 특정 채팅방에 참여 중인 멤버 참여자 찾기
*
* @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?
}

View File

@ -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<CharacterChatRoom, Long> {
/**
* 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리
*
* @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?
}

View File

@ -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}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.")
}
}
}

View File

@ -1,5 +1,6 @@
server: server:
shutdown: graceful shutdown: graceful
env: ${SERVER_ENV}
logging: logging:
level: level: