feat(chat): 채팅방 생성 API 구현
- 채팅방 생성 및 조회 기능 구현 - 외부 API 연동을 통한 세션 생성 로직 추가 - 채팅방 참여자(유저, 캐릭터) 추가 기능 구현 - UUID 기반 유저 ID 생성 로직 추가
This commit is contained in:
parent
694d9cd05a
commit
1bafbed17c
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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?
|
||||||
|
}
|
|
@ -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?
|
||||||
|
}
|
|
@ -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}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
|
env: ${SERVER_ENV}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|
Loading…
Reference in New Issue