Compare commits
No commits in common. "5d1c5fcc44948707e90fb3f9dee4496d2452d799" and "b0a6fc649889b1bce5854bbee1dec66fae415796" have entirely different histories.
5d1c5fcc44
...
b0a6fc6498
|
@ -1,166 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
|
@ -1,33 +0,0 @@
|
||||||
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 findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner>
|
fun findByActiveTrueOrderBySortOrderAsc(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,11 +14,6 @@ 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.findByIsActiveTrueOrderBySortOrderAsc(pageable)
|
return bannerRepository.findByActiveTrueOrderBySortOrderAsc(pageable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -11,7 +11,6 @@ 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
|
||||||
|
|
||||||
|
@ -24,34 +23,6 @@ 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 태그를 찾거나 생성하여 캐릭터에 연결
|
* 태그를 찾거나 생성하여 캐릭터에 연결
|
||||||
*/
|
*/
|
||||||
|
@ -172,27 +143,6 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 생성 및 관련 엔티티 연결
|
* 캐릭터 생성 및 관련 엔티티 연결
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
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()
|
|
|
@ -1,40 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
|
@ -1,142 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,136 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
|
@ -1,18 +0,0 @@
|
||||||
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>
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
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>
|
|
||||||
}
|
|
|
@ -1,490 +0,0 @@
|
||||||
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,7 +93,6 @@ 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,6 +1,5 @@
|
||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
env: ${SERVER_ENV}
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|
Loading…
Reference in New Issue