Compare commits

...

13 Commits

Author SHA1 Message Date
Klaus 5d1c5fcc44 fix(chat): 채팅방 메시지
- 메시지 DB 타입을 TEXT로 변경
2025-08-08 17:11:38 +09:00
Klaus ebad3b31b7 fix(chat): 채팅방 메시지 전송 API
- 빈 메시지이면 전송하지 않고 반환
2025-08-08 16:52:30 +09:00
Klaus 3e9f7f9e29 fix(chat): 채팅방, 채팅방 메시지, 채팅방 참여자 엔티티 이름 변경
- CharacterChatRoom -> ChatRoom
- CharacterChatMessage -> ChatMessage
- CharacterChatParticipant -> ChatParticipant
2025-08-08 16:47:47 +09:00
Klaus 4b3463e97c feat(chat): 채팅방 메시지 전송 API 구현 2025-08-08 16:41:53 +09:00
Klaus 002f2c2834 feat(chat): 채팅방 메시지 조회 API 구현 2025-08-08 16:00:30 +09:00
Klaus 1509ee0729 feat(chat): 채팅방 나가기 API 구현 2025-08-08 15:48:20 +09:00
Klaus 830e41dfa3 feat(chat): 채팅방 세션 조회 API 구현 2025-08-08 15:15:29 +09:00
Klaus 4d1f84cc5c feat(chat-room): 채팅방 목록 API 응답 구조 개편 및 최근 메시지/프로필 이미지 제공\n\n- 페이징 객체 제거: ApiResponse<List<ChatRoomListItemDto>> 형태로 반환\n- 메시지 보낸 시간 필드 제거\n- 상대방(캐릭터) 프로필 이미지 URL 제공 (imageHost/imagePath 조합 -> imageUrl)\n- 가장 최근 메시지 1개 미리보기 제공 (최대 25자, 초과 시 ... 처리)\n- 목록 조회 쿼리 투영 DTO 및 정렬 로직 개선 (최근 메시지 없으면 방 생성 시간 사용)\n- 비인증/미본인인증 사용자: 빈 리스트 반환 2025-08-08 14:27:25 +09:00
Klaus 1bafbed17c feat(chat): 채팅방 생성 API 구현
- 채팅방 생성 및 조회 기능 구현
- 외부 API 연동을 통한 세션 생성 로직 추가
- 채팅방 참여자(유저, 캐릭터) 추가 기능 구현
- UUID 기반 유저 ID 생성 로직 추가
2025-08-08 00:27:25 +09:00
Klaus 694d9cd05a feat(character chat room): 채팅방, 채팅메시지, 채팅방 참여자 엔티티 구성 2025-08-07 23:35:57 +09:00
Klaus 60172ae84d feat(character): 캐릭터 상세 조회 API 추가
- 캐릭터 ID로 상세 정보를 조회하는 API 엔드포인트 추가
- 캐릭터 상세 정보 조회 서비스 메서드 구현
- 캐릭터 상세 정보 응답 DTO 클래스 추가
2025-08-07 23:10:36 +09:00
Klaus 7e7a1122fa refactor(character): 최근 등록된 캐릭터 조회 로직 개선
조회할 때부터 isActive = true, limit 10개를 불러오도록 리팩토링
- ChatCharacterRepository에 findByIsActiveTrueOrderByCreatedAtDesc 메소드 추가
- ChatCharacterService의 getNewCharacters 메소드 수정
2025-08-07 22:40:06 +09:00
Klaus a1533c8e98 feat(character): 캐릭터 메인 API 추가 2025-08-07 22:33:29 +09:00
18 changed files with 1262 additions and 2 deletions

View File

@ -0,0 +1,166 @@
package kr.co.vividnext.sodalive.chat.character.controller
import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterDetailResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterMainResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterMemoryResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chat/character")
class ChatCharacterController(
private val service: ChatCharacterService,
private val bannerService: ChatCharacterBannerService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@GetMapping("/main")
fun getCharacterMain(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<CharacterMainResponse> = run {
// 배너 조회 (최대 10개)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
.content
.map {
CharacterBannerResponse(
characterId = it.chatCharacter.id!!,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
// 최근 대화한 캐릭터 조회 (현재는 빈 리스트)
val recentCharacters = service.getRecentCharacters()
.map {
RecentCharacter(
characterId = it.id!!,
name = it.name,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
// 인기 캐릭터 조회 (현재는 빈 리스트)
val popularCharacters = service.getPopularCharacters()
.map {
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
// 최신 캐릭터 조회 (최대 10개)
val newCharacters = service.getNewCharacters(10)
.map {
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
)
}
// 큐레이션 섹션 (현재는 빈 리스트)
val curationSections = emptyList<CurationSection>()
// 응답 생성
ApiResponse.ok(
CharacterMainResponse(
banners = banners,
recentCharacters = recentCharacters,
popularCharacters = popularCharacters,
newCharacters = newCharacters,
curationSections = curationSections
)
)
}
/**
* 캐릭터 상세 정보 조회 API
* 캐릭터 ID를 받아 해당 캐릭터의 상세 정보를 반환합니다.
*/
@GetMapping("/{characterId}")
fun getCharacterDetail(
@PathVariable characterId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
// 캐릭터 상세 정보 조회
val character = service.getCharacterDetail(characterId)
?: throw SodaException("캐릭터를 찾을 수 없습니다.")
// 태그, 가치관, 취미, 목표 추출
val tags = character.tagMappings.map { it.tag.tag }
val values = character.valueMappings.map { it.value.value }
val hobbies = character.hobbyMappings.map { it.hobby.hobby }
val goals = character.goalMappings.map { it.goal.goal }
// 메모리, 성격, 배경, 관계 변환
val memories = character.memories.map {
CharacterMemoryResponse(
title = it.title,
content = it.content,
emotion = it.emotion
)
}
val personalities = character.personalities.map {
CharacterPersonalityResponse(
trait = it.trait,
description = it.description
)
}
val backgrounds = character.backgrounds.map {
CharacterBackgroundResponse(
topic = it.topic,
description = it.description
)
}
val relationships = character.relationships.map { it.relationShip }
// 응답 생성
ApiResponse.ok(
CharacterDetailResponse(
characterId = character.id!!,
name = character.name,
description = character.description,
age = character.age,
gender = character.gender,
mbti = character.mbti,
speechPattern = character.speechPattern,
speechStyle = character.speechStyle,
appearance = character.appearance,
imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}",
memories = memories,
personalities = personalities,
backgrounds = backgrounds,
relationships = relationships,
tags = tags,
values = values,
hobbies = hobbies,
goals = goals
)
)
}
}

View File

@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.chat.character.dto
data class CharacterDetailResponse(
val characterId: Long,
val name: String,
val description: String,
val age: Int?,
val gender: String?,
val mbti: String?,
val speechPattern: String?,
val speechStyle: String?,
val appearance: String?,
val imageUrl: String,
val memories: List<CharacterMemoryResponse> = emptyList(),
val personalities: List<CharacterPersonalityResponse> = emptyList(),
val backgrounds: List<CharacterBackgroundResponse> = emptyList(),
val relationships: List<String> = emptyList(),
val tags: List<String> = emptyList(),
val values: List<String> = emptyList(),
val hobbies: List<String> = emptyList(),
val goals: List<String> = emptyList()
)
data class CharacterMemoryResponse(
val title: String,
val content: String,
val emotion: String
)
data class CharacterPersonalityResponse(
val trait: String,
val description: String
)
data class CharacterBackgroundResponse(
val topic: String,
val description: String
)

View File

@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.chat.character.dto
data class CharacterMainResponse(
val banners: List<CharacterBannerResponse>,
val recentCharacters: List<RecentCharacter>,
val popularCharacters: List<Character>,
val newCharacters: List<Character>,
val curationSections: List<CurationSection>
)
data class CurationSection(
val characterCurationId: Long,
val title: String,
val characters: List<Character>
)
data class Character(
val characterId: Long,
val name: String,
val description: String,
val imageUrl: String
)
data class RecentCharacter(
val characterId: Long,
val name: String,
val imageUrl: String
)
data class CharacterBannerResponse(
val characterId: Long,
val imageUrl: String
)

View File

@ -10,7 +10,7 @@ import org.springframework.stereotype.Repository
@Repository
interface ChatCharacterBannerRepository : JpaRepository<ChatCharacterBanner, Long> {
// 활성화된 배너 목록 조회 (정렬 순서대로)
fun findByActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner>
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner>
// 활성화된 배너 중 최대 정렬 순서 값 조회
@Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true")

View File

@ -14,6 +14,11 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findByName(name: String): ChatCharacter?
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
/**
* 활성화된 캐릭터를 생성일 기준 내림차순으로 조회
*/
fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter>
/**
* 이름, 설명, MBTI, 태그로 캐릭터 검색
*/

View File

@ -18,7 +18,7 @@ class ChatCharacterBannerService(
* 활성화된 모든 배너 조회 (정렬 순서대로)
*/
fun getActiveBanners(pageable: Pageable): Page<ChatCharacterBanner> {
return bannerRepository.findByActiveTrueOrderBySortOrderAsc(pageable)
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
}
/**

View File

@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepo
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -23,6 +24,34 @@ class ChatCharacterService(
private val goalRepository: ChatCharacterGoalRepository
) {
/**
* 최근에 대화한 캐릭터 목록 조회
* 현재는 채팅방 구현 전이므로 리스트 반환
*/
@Transactional(readOnly = true)
fun getRecentCharacters(): List<ChatCharacter> {
// 채팅방 구현 전이므로 빈 리스트 반환
return emptyList()
}
/**
* 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회
* 현재는 채팅방 구현 전이므로 리스트 반환
*/
@Transactional(readOnly = true)
fun getPopularCharacters(): List<ChatCharacter> {
// 채팅방 구현 전이므로 빈 리스트 반환
return emptyList()
}
/**
* 최근 등록된 캐릭터 목록 조회 (최대 10)
*/
@Transactional(readOnly = true)
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> {
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
}
/**
* 태그를 찾거나 생성하여 캐릭터에 연결
*/
@ -143,6 +172,27 @@ class ChatCharacterService(
return chatCharacterRepository.findById(id).orElse(null)
}
/**
* 캐릭터 ID로 상세 정보를 조회합니다.
* 태그, 가치관, 취미, 목표 등의 관계 정보도 함께 조회합니다.
*/
@Transactional(readOnly = true)
fun getCharacterDetail(id: Long): ChatCharacter? {
val character = findById(id) ?: return null
// 지연 로딩된 관계 데이터 초기화
character.tagMappings.size
character.valueMappings.size
character.hobbyMappings.size
character.goalMappings.size
character.memories.size
character.personalities.size
character.backgrounds.size
character.relationships.size
return character
}
/**
* 캐릭터 생성 관련 엔티티 연결
*/

View File

@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.chat.room
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class ChatMessage(
@Column(columnDefinition = "TEXT", nullable = false)
val message: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id", nullable = false)
val chatRoom: ChatRoom,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "participant_id", nullable = false)
val participant: ChatParticipant,
val isActive: Boolean = true
) : BaseEntity()

View File

@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.chat.room
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
class ChatParticipant(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id", nullable = false)
val chatRoom: ChatRoom,
@Enumerated(EnumType.STRING)
val participantType: ParticipantType,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
val member: Member? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_id")
val character: ChatCharacter? = null,
var isActive: Boolean = true
) : BaseEntity() {
@OneToMany(mappedBy = "participant", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
val messages: MutableList<ChatMessage> = mutableListOf()
}
enum class ParticipantType {
USER, CHARACTER
}

View File

@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.chat.room
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.OneToMany
@Entity
class ChatRoom(
val sessionId: String,
val title: String,
val isActive: Boolean = true
) : BaseEntity() {
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
val messages: MutableList<ChatMessage> = mutableListOf()
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
val participants: MutableList<ChatParticipant> = mutableListOf()
}

View File

@ -0,0 +1,142 @@
package kr.co.vividnext.sodalive.chat.room.controller
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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.RequestParam
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)
}
/**
* 내가 참여 중인 채팅방 목록 조회 API
* - 페이징(기본 20)
* - 가장 최근 메시지 기준 내림차순
*/
@GetMapping("/list")
fun listMyChatRooms(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null || member.auth == null) {
ApiResponse.ok(emptyList())
} else {
val response = chatRoomService.listMyChatRooms(member)
ApiResponse.ok(response)
}
}
/**
* 채팅방 메시지 조회 API
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")
* - messageId가 있으면 해당 ID 이전 20, 없으면 최신 20
*/
@GetMapping("/{chatRoomId}/messages")
fun getChatMessages(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@RequestParam(required = false) messageId: Long?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val response = chatRoomService.getChatMessages(member, chatRoomId, messageId)
ApiResponse.ok(response)
}
/**
* 세션 상태 조회 API
* - 채팅방 참여 여부 검증
* - 외부 API로 세션 상태 조회 active면 true, 아니면 false 반환
*/
@GetMapping("/{chatRoomId}/session")
fun getChatSessionStatus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId)
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)
}
/**
* 채팅방 메시지 전송 API
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")
* - 외부 API 호출 (/api/chat, POST) 재시도 최대 3
* - 성공 메시지/캐릭터 메시지 저장 캐릭터 메시지 리스트 반환
*/
@PostMapping("/{chatRoomId}/send")
fun sendMessage(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@RequestBody request: SendChatMessageRequest
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (request.message.isBlank()) {
ApiResponse.error()
} else {
ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message))
}
}
}

View File

@ -0,0 +1,136 @@
package kr.co.vividnext.sodalive.chat.room.dto
/**
* 채팅방 생성 요청 DTO
*/
data class CreateChatRoomRequest(
val characterId: Long
)
/**
* 채팅방 생성 응답 DTO
*/
data class CreateChatRoomResponse(
val chatRoomId: Long
)
/**
* 채팅방 목록 아이템 DTO (API 응답용)
*/
data class ChatRoomListItemDto(
val chatRoomId: Long,
val title: String,
val imageUrl: String,
val lastMessagePreview: String?
)
/**
* 채팅방 메시지 아이템 DTO (API 응답용)
*/
data class ChatMessageItemDto(
val messageId: Long,
val message: String,
val profileImageUrl: String,
val mine: Boolean
)
/**
* 채팅방 목록 쿼리 DTO (레포지토리 투영용)
*/
data class ChatRoomListQueryDto(
val chatRoomId: Long,
val title: String,
val imagePath: String?
)
/**
* 외부 API 채팅 세션 생성 응답 DTO
*/
data class ExternalChatSessionCreateResponse(
val success: Boolean,
val message: String?,
val data: ExternalChatSessionCreateData?
)
/**
* 외부 API 채팅 세션 생성 데이터 DTO
* 공통: sessionId, status
* 생성 전용: userId, characterId, character, createdAt
*/
data class ExternalChatSessionCreateData(
val sessionId: String,
val userId: String,
val characterId: String,
val character: ExternalCharacterData,
val status: String,
val createdAt: String
)
/**
* 외부 API 채팅 세션 조회 응답 DTO
*/
data class ExternalChatSessionGetResponse(
val success: Boolean,
val message: String?,
val data: ExternalChatSessionGetData?
)
/**
* 외부 API 채팅 세션 조회 데이터 DTO
* 세션 조회에서 사용하는 공통 필드만 포함
*/
data class ExternalChatSessionGetData(
val sessionId: String,
val status: String
)
/**
* 외부 API 캐릭터 데이터 DTO
*/
data class ExternalCharacterData(
val id: String,
val name: String,
val age: String,
val gender: String
)
/**
* 채팅 메시지 전송 요청 DTO
*/
data class SendChatMessageRequest(
val message: String
)
/**
* 채팅 메시지 전송 응답 DTO (캐릭터 메시지 리스트)
*/
data class SendChatMessageResponse(
val characterMessages: List<ChatMessageItemDto>
)
/**
* 외부 API 채팅 전송 응답 DTO
*/
data class ExternalChatSendResponse(
val success: Boolean,
val message: String?,
val data: ExternalChatSendData?
)
/**
* 외부 API 채팅 전송 데이터 DTO
*/
data class ExternalChatSendData(
val sessionId: String,
val characterResponse: ExternalCharacterMessage
)
/**
* 외부 API 캐릭터 메시지 DTO
*/
data class ExternalCharacterMessage(
val id: String,
val content: String,
val timestamp: String,
val messageType: String
)

View File

@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.chat.room.repository
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface ChatMessageRepository : JpaRepository<ChatMessage, Long> {
fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage?
fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List<ChatMessage>
fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
chatRoom: ChatRoom,
id: Long
): List<ChatMessage>
}

View File

@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.chat.room.repository
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface ChatParticipantRepository : JpaRepository<ChatParticipant, Long> {
/**
* 특정 채팅방에 참여 중인 멤버 참여자 찾기
*/
fun findByChatRoomAndMemberAndIsActiveTrue(
chatRoom: ChatRoom,
member: Member
): ChatParticipant?
/**
* 특정 채팅방에 특정 타입(CHARACTER/USER)으로 활성 상태인 참여자 찾기
*/
fun findByChatRoomAndParticipantTypeAndIsActiveTrue(
chatRoom: ChatRoom,
participantType: ParticipantType
): ChatParticipant?
/**
* 특정 채팅방의 활성 USER 참여자
*/
fun countByChatRoomAndParticipantTypeAndIsActiveTrue(
chatRoom: ChatRoom,
participantType: ParticipantType
): Long
}

View File

@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.chat.room.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
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 ChatRoomRepository : JpaRepository<ChatRoom, Long> {
/**
* 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리
*/
@Query(
"""
SELECT DISTINCT r FROM ChatRoom 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
): ChatRoom?
/**
* 멤버가 참여 중인 채팅방을 최근 메시지 시간 순으로 페이징 조회
* - 메시지가 없으면 생성 시간(createdAt)으로 대체
*/
@Query(
value = """
SELECT new kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto(
r.id,
r.title,
pc.character.imagePath
)
FROM ChatRoom r
JOIN r.participants p
JOIN r.participants pc
LEFT JOIN r.messages m
WHERE p.member = :member
AND p.isActive = true
AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER
AND pc.isActive = true
AND r.isActive = true
GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath
ORDER BY COALESCE(MAX(m.createdAt), r.createdAt) DESC
"""
)
fun findMemberRoomsOrderByLastMessageDesc(
@Param("member") member: Member
): List<ChatRoomListQueryDto>
}

View File

@ -0,0 +1,490 @@
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.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.slf4j.LoggerFactory
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: ChatRoomRepository,
private val participantRepository: ChatParticipantRepository,
private val messageRepository: ChatMessageRepository,
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,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
private val log = LoggerFactory.getLogger(ChatRoomService::class.java)
/**
* 채팅방 생성 또는 조회
*
* @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 = ChatRoom(
sessionId = sessionId,
title = character.name,
isActive = true
)
val savedChatRoom = chatRoomRepository.save(chatRoom)
// 6. 채팅방 참여자 추가 (멤버)
val memberParticipant = ChatParticipant(
chatRoom = savedChatRoom,
participantType = ParticipantType.USER,
member = member,
character = null,
isActive = true
)
participantRepository.save(memberParticipant)
// 7. 채팅방 참여자 추가 (캐릭터)
val characterParticipant = ChatParticipant(
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,
ExternalChatSessionCreateResponse::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}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.")
}
}
@Transactional(readOnly = true)
fun listMyChatRooms(member: Member): List<ChatRoomListItemDto> {
val rooms: List<ChatRoomListQueryDto> = chatRoomRepository.findMemberRoomsOrderByLastMessageDesc(member)
return rooms.map { q ->
val room = ChatRoom(
sessionId = "",
title = q.title,
isActive = true
).apply { id = q.chatRoomId }
val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room)
val preview = latest?.message?.let { msg ->
if (msg.length <= 25) msg else msg.substring(0, 25) + "..."
}
ChatRoomListItemDto(
chatRoomId = q.chatRoomId,
title = q.title,
imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}",
lastMessagePreview = preview
)
}
}
@Transactional(readOnly = true)
fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
if (participant == null) {
throw SodaException("잘못된 접근입니다")
}
return fetchSessionActive(room.sessionId)
}
private fun fetchSessionActive(sessionId: String): Boolean {
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)
val httpEntity = HttpEntity(null, headers)
val response = restTemplate.exchange(
"$apiUrl/api/session/$sessionId",
HttpMethod.GET,
httpEntity,
String::class.java
)
val objectMapper = ObjectMapper()
val apiResponse = objectMapper.readValue(
response.body,
ExternalChatSessionGetResponse::class.java
)
// success가 false이면 throw
if (!apiResponse.success) {
throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
}
val status = apiResponse.data?.status
return status == "active"
} catch (e: Exception) {
e.printStackTrace()
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)
}
@Transactional(readOnly = true)
fun getChatMessages(member: Member, chatRoomId: Long, beforeMessageId: Long?): List<ChatMessageItemDto> {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
if (participant == null) {
throw SodaException("잘못된 접근입니다")
}
val messages = if (beforeMessageId != null) {
messageRepository.findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, beforeMessageId)
} else {
messageRepository.findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(room)
}
return messages.map { msg ->
val sender = msg.participant
val profilePath = when (sender.participantType) {
ParticipantType.USER -> sender.member?.profileImage
ParticipantType.CHARACTER -> sender.character?.imagePath
}
val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
ChatMessageItemDto(
messageId = msg.id!!,
message = msg.message,
profileImageUrl = imageUrl,
mine = sender.member?.id == member.id
)
}
}
@Transactional
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
// 1) 방 존재 확인
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
// 2) 참여 여부 확인 (USER)
val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
// 3) 캐릭터 참여자 조회
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
room,
ParticipantType.CHARACTER
) ?: throw SodaException("잘못된 접근입니다")
val character = characterParticipant.character
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
// 4) 외부 API 호출 준비
val userId = generateUserId(member.id!!)
val sessionId = room.sessionId
val characterUUID = character.characterUUID
// 5) 외부 API 호출 (최대 3회 재시도)
val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
// 6) 내 메시지 저장
val myMsgEntity = ChatMessage(
message = message,
chatRoom = room,
participant = myParticipant,
isActive = true
)
messageRepository.save(myMsgEntity)
// 7) 캐릭터 메시지 저장
val characterMsgEntity = ChatMessage(
message = characterReply,
chatRoom = room,
participant = characterParticipant,
isActive = true
)
val savedCharacterMsg = messageRepository.save(characterMsgEntity)
// 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소)
val profilePath = characterParticipant.character?.imagePath
val defaultPath = profilePath ?: "profile/default-profile.png"
val imageUrl = "$imageHost/$defaultPath"
val dto = ChatMessageItemDto(
messageId = savedCharacterMsg.id!!,
message = savedCharacterMsg.message,
profileImageUrl = imageUrl,
mine = false
)
return SendChatMessageResponse(characterMessages = listOf(dto))
}
private fun callExternalApiForChatSendWithRetry(
userId: String,
characterUUID: String,
message: String,
sessionId: String
): String {
val maxAttempts = 3
var attempt = 0
while (attempt < maxAttempts) {
attempt++
try {
return callExternalApiForChatSend(userId, characterUUID, message, sessionId)
} catch (e: Exception) {
log.warn("[chat] 외부 채팅 전송 실패 attempt={}, error={}", attempt, e.message)
}
}
log.error("[chat] 외부 채팅 전송 최종 실패 attempts={}", maxAttempts)
throw SodaException("메시지 전송을 실패했습니다.")
}
private fun callExternalApiForChatSend(
userId: String,
characterUUID: String,
message: String,
sessionId: String
): String {
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 requestBody = mapOf(
"userId" to userId,
"characterId" to characterUUID,
"message" to message,
"sessionId" to sessionId
)
val httpEntity = HttpEntity(requestBody, headers)
val response = restTemplate.exchange(
"$apiUrl/api/chat",
HttpMethod.POST,
httpEntity,
String::class.java
)
val objectMapper = ObjectMapper()
val apiResponse = objectMapper.readValue(
response.body,
ExternalChatSendResponse::class.java
)
if (!apiResponse.success) {
throw SodaException(apiResponse.message ?: "메시지 전송을 실패했습니다.")
}
val data = apiResponse.data ?: throw SodaException("메시지 전송을 실패했습니다.")
val characterContent = data.characterResponse.content
if (characterContent.isBlank()) {
throw SodaException("메시지 전송을 실패했습니다.")
}
return characterContent
}
}

View File

@ -93,6 +93,7 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
.antMatchers("/ad-tracking/app-launch").permitAll()
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
.anyRequest().authenticated()
.and()
.build()

View File

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