feat(user-creator-chat): 유저 크리에이터 채팅방을 추가한다

유저와 크리에이터 간 텍스트/음성 메시지, SSE presence, 조건부 푸시 흐름을 신규 도메인으로 분리한다.
This commit is contained in:
2026-05-13 18:02:11 +09:00
parent 6e22198b6f
commit 1daf67fa49
13 changed files with 1318 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class UserCreatorChatMessage(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id", nullable = false)
var chatRoom: UserCreatorChatRoom,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "participant_id", nullable = false)
var participant: UserCreatorChatParticipant,
@Enumerated(EnumType.STRING)
@Column(name = "message_type", nullable = false)
var messageType: UserCreatorChatMessageType,
@Column(columnDefinition = "TEXT")
var textMessage: String? = null,
@Column(length = 1024)
var voiceMessage: String? = null,
var isActive: Boolean = true
) : BaseEntity()
enum class UserCreatorChatMessageType {
TEXT, VOICE
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@Entity
class UserCreatorChatParticipant(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id", nullable = false)
var chatRoom: UserCreatorChatRoom,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member,
var isActive: Boolean = true
) : BaseEntity()

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat
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 UserCreatorChatRoom(
var isActive: Boolean = true
) : BaseEntity() {
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
var participants: MutableList<UserCreatorChatParticipant> = mutableListOf()
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
var messages: MutableList<UserCreatorChatMessage> = mutableListOf()
}

View File

@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.controller
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.springframework.http.MediaType
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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/api/v2/user-creator-chat/rooms")
class UserCreatorChatController(
private val service: UserCreatorChatService
) {
@PostMapping("/create")
fun createOrGetRoom(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestBody request: CreateUserCreatorChatRoomRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.createOrGetRoom(member, request.creatorId))
}
@GetMapping("/{roomId}/open")
fun openRoom(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long,
@RequestParam(defaultValue = "20") limit: Int
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.openRoom(member, roomId, limit))
}
@PostMapping("/{roomId}/events/disconnect")
fun disconnectRealtime(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
service.disconnectRealtime(member, roomId)
ApiResponse.ok(true)
}
@GetMapping("/{roomId}/messages")
fun getMessages(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long,
@RequestParam(required = false) cursor: Long?,
@RequestParam(defaultValue = "20") limit: Int
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.getMessages(member, roomId, cursor, limit))
}
@GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun connectEvents(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
service.connect(member, roomId)
}
@PostMapping("/{roomId}/messages/text")
fun sendTextMessage(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long,
@RequestBody request: SendUserCreatorTextMessageRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.sendTextMessage(member, roomId, request))
}
@PostMapping("/{roomId}/messages/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun sendVoiceMessage(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long,
@RequestPart("voiceMessageFile") voiceMessageFile: MultipartFile,
@RequestPart("request") requestString: String
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.sendVoiceMessage(member, roomId, voiceMessageFile, requestString))
}
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.dto
data class CreateUserCreatorChatRoomRequest(
val creatorId: Long
)
data class CreateUserCreatorChatRoomResponse(
val roomId: Long
)
data class SendUserCreatorTextMessageRequest(
val textMessage: String
)
data class SendUserCreatorVoiceMessageRequest(
val recipientId: Long? = null
)
data class SendUserCreatorChatMessageResponse(
val message: UserCreatorChatMessageItemDto,
val deliveredRealtime: Boolean,
val pushSent: Boolean
)
data class UserCreatorChatRoomOpenResponse(
val roomId: Long,
val messages: List<UserCreatorChatMessageItemDto>,
val hasMore: Boolean,
val nextCursor: Long?
)
data class UserCreatorChatMessagesPageResponse(
val messages: List<UserCreatorChatMessageItemDto>,
val hasMore: Boolean,
val nextCursor: Long?
)
data class UserCreatorChatMessageItemDto(
val messageId: Long,
val messageType: String,
val mine: Boolean,
val createdAt: Long,
val textMessage: String?,
val voiceMessageUrl: String?,
val senderId: Long,
val senderNickname: String,
val senderProfileImageUrl: String
)

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.repository
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UserCreatorChatMessageRepository : JpaRepository<UserCreatorChatMessage, Long> {
fun findByChatRoomAndIsActiveTrueOrderByIdDesc(
chatRoom: UserCreatorChatRoom,
pageable: Pageable
): List<UserCreatorChatMessage>
fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
chatRoom: UserCreatorChatRoom,
id: Long,
pageable: Pageable
): List<UserCreatorChatMessage>
fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: UserCreatorChatRoom, id: Long): Boolean
}

View File

@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.repository
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
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 UserCreatorChatParticipantRepository : JpaRepository<UserCreatorChatParticipant, Long> {
@Query(
"""
select p from UserCreatorChatParticipant p
where p.isActive = true
and p.chatRoom.id = :roomId
and p.member.id = :memberId
"""
)
fun findActiveByRoomIdAndMemberId(
@Param("roomId") roomId: Long,
@Param("memberId") memberId: Long
): UserCreatorChatParticipant?
@Query(
"""
select p from UserCreatorChatParticipant p
where p.isActive = true
and p.chatRoom.id = :roomId
and p.member.id <> :memberId
"""
)
fun findActiveOpponent(
@Param("roomId") roomId: Long,
@Param("memberId") memberId: Long
): UserCreatorChatParticipant?
}

View File

@@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.repository
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
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 UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Long> {
fun findByIdAndIsActiveTrue(id: Long): UserCreatorChatRoom?
@Query(
"""
select r from UserCreatorChatRoom r
where r.isActive = true
and exists (
select 1 from UserCreatorChatParticipant p1
where p1.chatRoom = r
and p1.isActive = true
and p1.member.id = :firstMemberId
)
and exists (
select 1 from UserCreatorChatParticipant p2
where p2.chatRoom = r
and p2.isActive = true
and p2.member.id = :secondMemberId
)
"""
)
fun findActiveRoomByParticipantMemberIds(
@Param("firstMemberId") firstMemberId: Long,
@Param("secondMemberId") secondMemberId: Long
): UserCreatorChatRoom?
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.service
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.stereotype.Service
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.io.IOException
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
@Service
class UserCreatorChatRealtimeService(
private val stringRedisTemplate: StringRedisTemplate
) {
private val emitters = ConcurrentHashMap<String, SseEmitter>()
fun connect(roomId: Long, memberId: Long): SseEmitter {
val emitter = SseEmitter(SSE_TIMEOUT_MILLIS)
val key = emitterKey(roomId, memberId)
emitters[key] = emitter
markPresent(roomId, memberId)
emitter.onCompletion { disconnect(roomId, memberId) }
emitter.onTimeout { disconnect(roomId, memberId) }
emitter.onError { disconnect(roomId, memberId) }
sendConnectEvent(emitter)
return emitter
}
fun disconnect(roomId: Long, memberId: Long) {
emitters.remove(emitterKey(roomId, memberId))
stringRedisTemplate.delete(presenceKey(roomId, memberId))
}
fun isMemberInRoom(roomId: Long, memberId: Long): Boolean {
return stringRedisTemplate.hasKey(presenceKey(roomId, memberId))
}
fun sendMessage(roomId: Long, memberId: Long, message: UserCreatorChatMessageItemDto): Boolean {
val emitter = emitters[emitterKey(roomId, memberId)] ?: return false
return try {
emitter.send(
SseEmitter.event()
.id(message.messageId.toString())
.name("message")
.reconnectTime(SSE_RECONNECT_MILLIS)
.data(message)
)
markPresent(roomId, memberId)
true
} catch (_: IOException) {
disconnect(roomId, memberId)
false
} catch (_: IllegalStateException) {
disconnect(roomId, memberId)
false
}
}
private fun sendConnectEvent(emitter: SseEmitter) {
try {
emitter.send(
SseEmitter.event()
.name("connected")
.reconnectTime(SSE_RECONNECT_MILLIS)
.data("connected")
)
} catch (e: IOException) {
emitter.completeWithError(e)
}
}
private fun markPresent(roomId: Long, memberId: Long) {
stringRedisTemplate.opsForValue().set(presenceKey(roomId, memberId), "1", Duration.ofSeconds(PRESENCE_TTL_SECONDS))
}
private fun emitterKey(roomId: Long, memberId: Long) = "$roomId:$memberId"
private fun presenceKey(roomId: Long, memberId: Long) = "v2:user-creator-chat:presence:$roomId:$memberId"
companion object {
private const val SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L
private const val SSE_RECONNECT_MILLIS = 3000L
private const val PRESENCE_TTL_SECONDS = 60L
}
}

View File

@@ -0,0 +1,244 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.service
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorChatMessageResponse
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorVoiceMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenResponse
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.ZoneId
@Service
@Transactional(readOnly = true)
class UserCreatorChatService(
private val roomRepository: UserCreatorChatRoomRepository,
private val participantRepository: UserCreatorChatParticipantRepository,
private val messageRepository: UserCreatorChatMessageRepository,
private val memberRepository: MemberRepository,
private val blockMemberRepository: BlockMemberRepository,
private val realtimeService: UserCreatorChatRealtimeService,
private val applicationEventPublisher: ApplicationEventPublisher,
private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@Transactional
fun createOrGetRoom(member: Member, creatorId: Long): CreateUserCreatorChatRoomResponse {
val creator = memberRepository.findById(creatorId).orElseThrow {
SodaException(messageKey = "message.error.recipient_not_found")
}
validateRecipient(member, creator)
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, creator.id!!)
if (existingRoom != null) {
return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!)
}
val room = roomRepository.save(UserCreatorChatRoom())
participantRepository.save(UserCreatorChatParticipant(room, member))
participantRepository.save(UserCreatorChatParticipant(room, creator))
return CreateUserCreatorChatRoomResponse(roomId = room.id!!)
}
@Transactional
fun openRoom(member: Member, roomId: Long, limit: Int = 20): UserCreatorChatRoomOpenResponse {
val room = findRoom(roomId)
requireParticipant(roomId, member.id!!)
val page = getMessages(member, roomId, cursor = null, limit = limit)
return UserCreatorChatRoomOpenResponse(
roomId = room.id!!,
messages = page.messages,
hasMore = page.hasMore,
nextCursor = page.nextCursor
)
}
fun getMessages(member: Member, roomId: Long, cursor: Long?, limit: Int = 20): UserCreatorChatMessagesPageResponse {
val room = findRoom(roomId)
requireParticipant(roomId, member.id!!)
val pageable = PageRequest.of(0, limit.coerceIn(1, 100))
val fetched = if (cursor != null) {
messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable)
} else {
messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable)
}
val nextCursor = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id
val hasMore = nextCursor?.let { messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, it) } ?: false
return UserCreatorChatMessagesPageResponse(
messages = fetched.sortedBy { it.createdAt }.map { toMessageItemDto(it, member) },
hasMore = hasMore,
nextCursor = nextCursor
)
}
@Transactional
fun sendTextMessage(
member: Member,
roomId: Long,
request: SendUserCreatorTextMessageRequest
): SendUserCreatorChatMessageResponse {
if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
val context = resolveSendContext(member, roomId)
val message = messageRepository.save(
UserCreatorChatMessage(
chatRoom = context.room,
participant = context.senderParticipant,
messageType = UserCreatorChatMessageType.TEXT,
textMessage = request.textMessage
)
)
return deliverMessage(message, member, context.opponentParticipant)
}
@Transactional
fun sendVoiceMessage(
member: Member,
roomId: Long,
voiceMessageFile: MultipartFile,
requestString: String
): SendUserCreatorChatMessageResponse {
objectMapper.readValue(requestString, SendUserCreatorVoiceMessageRequest::class.java)
val context = resolveSendContext(member, roomId)
val message = messageRepository.save(
UserCreatorChatMessage(
chatRoom = context.room,
participant = context.senderParticipant,
messageType = UserCreatorChatMessageType.VOICE
)
)
val metadata = ObjectMetadata()
metadata.contentLength = voiceMessageFile.size
message.voiceMessage = s3Uploader.upload(
inputStream = voiceMessageFile.inputStream,
bucket = bucket,
filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}",
metadata = metadata
)
return deliverMessage(message, member, context.opponentParticipant)
}
fun connect(member: Member, roomId: Long) = run {
requireParticipant(roomId, member.id!!)
realtimeService.connect(roomId, member.id!!)
}
fun disconnectRealtime(member: Member, roomId: Long) {
requireParticipant(roomId, member.id!!)
realtimeService.disconnect(roomId, member.id!!)
}
private fun resolveSendContext(member: Member, roomId: Long): SendContext {
val room = findRoom(roomId)
val senderParticipant = requireParticipant(roomId, member.id!!)
val opponentParticipant = participantRepository.findActiveOpponent(roomId, member.id!!)
?: throw SodaException(messageKey = "chat.room.invalid_access")
validateRecipient(member, opponentParticipant.member)
return SendContext(room, senderParticipant, opponentParticipant)
}
private fun deliverMessage(
message: UserCreatorChatMessage,
member: Member,
opponentParticipant: UserCreatorChatParticipant
): SendUserCreatorChatMessageResponse {
val opponent = opponentParticipant.member
val item = toMessageItemDto(message, member)
val opponentPresent = realtimeService.isMemberInRoom(message.chatRoom.id!!, opponent.id!!)
if (opponentPresent) {
val delivered = realtimeService.sendMessage(message.chatRoom.id!!, opponent.id!!, item)
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = delivered, pushSent = false)
}
publishMessagePush(message, member, opponent)
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true)
}
private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) {
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
"message.fcm.voice_received"
} else {
"message.fcm.text_received"
}
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.MESSAGE,
titleKey = "message.fcm.title",
messageKey = messageKey,
senderMemberId = sender.id,
args = listOf(sender.nickname),
recipients = listOf(opponent.id!!),
messageId = message.id
)
)
}
private fun validateRecipient(sender: Member, recipient: Member) {
if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive")
if (sender.id == recipient.id) throw SodaException(messageKey = "common.error.invalid_request")
if (blockMemberRepository.isBlocked(blockedMemberId = sender.id!!, memberId = recipient.id!!)) {
throw SodaException(messageKey = "message.error.blocked_by_recipient")
}
}
private fun findRoom(roomId: Long): UserCreatorChatRoom {
return roomRepository.findByIdAndIsActiveTrue(roomId)
?: throw SodaException(messageKey = "chat.error.room_not_found")
}
private fun requireParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant {
return participantRepository.findActiveByRoomIdAndMemberId(roomId, memberId)
?: throw SodaException(messageKey = "chat.room.invalid_access")
}
private fun toMessageItemDto(message: UserCreatorChatMessage, member: Member): UserCreatorChatMessageItemDto {
val sender = message.participant.member
val profilePath = sender.profileImage ?: "profile/default-profile.png"
return UserCreatorChatMessageItemDto(
messageId = message.id!!,
messageType = message.messageType.name,
mine = sender.id == member.id,
createdAt = message.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L,
textMessage = message.textMessage,
voiceMessageUrl = message.voiceMessage?.let { "$cloudFrontHost/$it" },
senderId = sender.id!!,
senderNickname = sender.nickname,
senderProfileImageUrl = "$cloudFrontHost/$profilePath"
)
}
private data class SendContext(
val room: UserCreatorChatRoom,
val senderParticipant: UserCreatorChatParticipant,
val opponentParticipant: UserCreatorChatParticipant
)
}