Compare commits
13 Commits
b0a6fc6498
...
5d1c5fcc44
Author | SHA1 | Date |
---|---|---|
|
5d1c5fcc44 | |
|
ebad3b31b7 | |
|
3e9f7f9e29 | |
|
4b3463e97c | |
|
002f2c2834 | |
|
1509ee0729 | |
|
830e41dfa3 | |
|
4d1f84cc5c | |
|
1bafbed17c | |
|
694d9cd05a | |
|
60172ae84d | |
|
7e7a1122fa | |
|
a1533c8e98 |
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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
|
||||||
|
)
|
|
@ -10,7 +10,7 @@ import org.springframework.stereotype.Repository
|
||||||
@Repository
|
@Repository
|
||||||
interface ChatCharacterBannerRepository : JpaRepository<ChatCharacterBanner, Long> {
|
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")
|
@Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true")
|
||||||
|
|
|
@ -14,6 +14,11 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||||
fun findByName(name: String): ChatCharacter?
|
fun findByName(name: String): ChatCharacter?
|
||||||
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 캐릭터를 생성일 기준 내림차순으로 조회
|
||||||
|
*/
|
||||||
|
fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이름, 설명, MBTI, 태그로 캐릭터 검색
|
* 이름, 설명, MBTI, 태그로 캐릭터 검색
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -18,7 +18,7 @@ class ChatCharacterBannerService(
|
||||||
* 활성화된 모든 배너 조회 (정렬 순서대로)
|
* 활성화된 모든 배너 조회 (정렬 순서대로)
|
||||||
*/
|
*/
|
||||||
fun getActiveBanners(pageable: Pageable): Page<ChatCharacterBanner> {
|
fun getActiveBanners(pageable: Pageable): Page<ChatCharacterBanner> {
|
||||||
return bannerRepository.findByActiveTrueOrderBySortOrderAsc(pageable)
|
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@ -23,6 +24,34 @@ class ChatCharacterService(
|
||||||
private val goalRepository: ChatCharacterGoalRepository
|
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)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 생성 및 관련 엔티티 연결
|
* 캐릭터 생성 및 관련 엔티티 연결
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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()
|
|
@ -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
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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>
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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>
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -93,6 +93,7 @@ class SecurityConfig(
|
||||||
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
|
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
|
||||||
.antMatchers("/ad-tracking/app-launch").permitAll()
|
.antMatchers("/ad-tracking/app-launch").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
||||||
|
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
.and()
|
.and()
|
||||||
.build()
|
.build()
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
|
env: ${SERVER_ENV}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|
Loading…
Reference in New Issue