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
|
||||
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")
|
||||
|
|
|
@ -14,6 +14,11 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
|||
fun findByName(name: String): ChatCharacter?
|
||||
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
||||
|
||||
/**
|
||||
* 활성화된 캐릭터를 생성일 기준 내림차순으로 조회
|
||||
*/
|
||||
fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter>
|
||||
|
||||
/**
|
||||
* 이름, 설명, MBTI, 태그로 캐릭터 검색
|
||||
*/
|
||||
|
|
|
@ -18,7 +18,7 @@ class ChatCharacterBannerService(
|
|||
* 활성화된 모든 배너 조회 (정렬 순서대로)
|
||||
*/
|
||||
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.ChatCharacterTagRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
|
@ -23,6 +24,34 @@ class ChatCharacterService(
|
|||
private val goalRepository: ChatCharacterGoalRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* 최근에 대화한 캐릭터 목록 조회
|
||||
* 현재는 채팅방 구현 전이므로 빈 리스트 반환
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getRecentCharacters(): List<ChatCharacter> {
|
||||
// 채팅방 구현 전이므로 빈 리스트 반환
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회
|
||||
* 현재는 채팅방 구현 전이므로 빈 리스트 반환
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getPopularCharacters(): List<ChatCharacter> {
|
||||
// 채팅방 구현 전이므로 빈 리스트 반환
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 등록된 캐릭터 목록 조회 (최대 10개)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> {
|
||||
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그를 찾거나 생성하여 캐릭터에 연결
|
||||
*/
|
||||
|
@ -143,6 +172,27 @@ class ChatCharacterService(
|
|||
return chatCharacterRepository.findById(id).orElse(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 ID로 상세 정보를 조회합니다.
|
||||
* 태그, 가치관, 취미, 목표 등의 관계 정보도 함께 조회합니다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getCharacterDetail(id: Long): ChatCharacter? {
|
||||
val character = findById(id) ?: return null
|
||||
|
||||
// 지연 로딩된 관계 데이터 초기화
|
||||
character.tagMappings.size
|
||||
character.valueMappings.size
|
||||
character.hobbyMappings.size
|
||||
character.goalMappings.size
|
||||
character.memories.size
|
||||
character.personalities.size
|
||||
character.backgrounds.size
|
||||
character.relationships.size
|
||||
|
||||
return character
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 생성 및 관련 엔티티 연결
|
||||
*/
|
||||
|
|
|
@ -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("/ad-tracking/app-launch").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.build()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
server:
|
||||
shutdown: graceful
|
||||
env: ${SERVER_ENV}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
|
Loading…
Reference in New Issue