feat(user-creator-chat): 유저 크리에이터 채팅방을 추가한다
유저와 크리에이터 간 텍스트/음성 메시지, SSE presence, 조건부 푸시 흐름을 신규 도메인으로 분리한다.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
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.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
|
||||
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 kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatRealtimeService
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
class UserCreatorChatServiceTest {
|
||||
private lateinit var roomRepository: UserCreatorChatRoomRepository
|
||||
private lateinit var participantRepository: UserCreatorChatParticipantRepository
|
||||
private lateinit var messageRepository: UserCreatorChatMessageRepository
|
||||
private lateinit var memberRepository: MemberRepository
|
||||
private lateinit var blockMemberRepository: BlockMemberRepository
|
||||
private lateinit var realtimeService: UserCreatorChatRealtimeService
|
||||
private lateinit var eventPublisher: ApplicationEventPublisher
|
||||
private lateinit var service: UserCreatorChatService
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
roomRepository = Mockito.mock(UserCreatorChatRoomRepository::class.java)
|
||||
participantRepository = Mockito.mock(UserCreatorChatParticipantRepository::class.java)
|
||||
messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java)
|
||||
memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
|
||||
realtimeService = Mockito.mock(UserCreatorChatRealtimeService::class.java)
|
||||
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
|
||||
service = UserCreatorChatService(
|
||||
roomRepository = roomRepository,
|
||||
participantRepository = participantRepository,
|
||||
messageRepository = messageRepository,
|
||||
memberRepository = memberRepository,
|
||||
blockMemberRepository = blockMemberRepository,
|
||||
realtimeService = realtimeService,
|
||||
applicationEventPublisher = eventPublisher,
|
||||
objectMapper = ObjectMapper(),
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
bucket = "test-bucket",
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("활성 유저-크리에이터 방이 없으면 새 방과 참여자를 생성한다")
|
||||
fun shouldCreateRoomAndParticipantsWhenActiveRoomDoesNotExist() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(creator))
|
||||
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
|
||||
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatRoom).apply { id = 10L }
|
||||
}
|
||||
|
||||
val response = service.createOrGetRoom(user, 2L)
|
||||
|
||||
assertEquals(10L, response.roomId)
|
||||
val roomCaptor = ArgumentCaptor.forClass(UserCreatorChatRoom::class.java)
|
||||
Mockito.verify(roomRepository).save(roomCaptor.capture())
|
||||
Mockito.verify(participantRepository, Mockito.times(2)).save(Mockito.any(UserCreatorChatParticipant::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상대방이 같은 방에 입장 중이면 텍스트 메시지를 실시간 전송하고 푸시를 보내지 않는다")
|
||||
fun shouldSendRealtimeMessageWithoutPushWhenOpponentIsPresent() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
val room = room(10L)
|
||||
val senderParticipant = participant(100L, room, user)
|
||||
val recipientParticipant = participant(101L, room, creator)
|
||||
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
|
||||
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
|
||||
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true)
|
||||
Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())).thenReturn(true)
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 200L }
|
||||
}
|
||||
|
||||
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||
|
||||
assertEquals(200L, response.message.messageId)
|
||||
assertEquals("hello", response.message.textMessage)
|
||||
assertTrue(response.deliveredRealtime)
|
||||
assertFalse(response.pushSent)
|
||||
Mockito.verify(realtimeService).sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())
|
||||
Mockito.verifyNoInteractions(eventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상대방이 같은 방에 없으면 텍스트 메시지를 저장하고 푸시 이벤트를 발행한다")
|
||||
fun shouldPublishPushEventWhenOpponentIsNotPresent() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
val room = room(10L)
|
||||
val senderParticipant = participant(100L, room, user)
|
||||
val recipientParticipant = participant(101L, room, creator)
|
||||
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
|
||||
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
|
||||
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(false)
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 201L }
|
||||
}
|
||||
|
||||
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||
|
||||
assertFalse(response.deliveredRealtime)
|
||||
assertTrue(response.pushSent)
|
||||
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
||||
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
||||
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
||||
assertEquals(201L, eventCaptor.value.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상대방이 입장 중이면 실시간 전송 실패 시에도 보완 푸시를 보내지 않는다")
|
||||
fun shouldNotFallbackToPushWhenRealtimeDeliveryFailsForPresentOpponent() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
val room = room(10L)
|
||||
val senderParticipant = participant(100L, room, user)
|
||||
val recipientParticipant = participant(101L, room, creator)
|
||||
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
|
||||
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
|
||||
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true)
|
||||
Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem()))
|
||||
.thenReturn(false)
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 202L }
|
||||
}
|
||||
|
||||
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||
|
||||
assertFalse(response.deliveredRealtime)
|
||||
assertFalse(response.pushSent)
|
||||
Mockito.verifyNoInteractions(eventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
||||
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
val room = room(10L)
|
||||
val userParticipant = participant(100L, room, user)
|
||||
val creatorParticipant = participant(101L, room, creator)
|
||||
val olderMessage = textMessage(
|
||||
id = 298L,
|
||||
room = room,
|
||||
participant = userParticipant,
|
||||
text = "older",
|
||||
createdAt = LocalDateTime.of(2026, 5, 13, 10, 0)
|
||||
)
|
||||
val newerMessage = textMessage(
|
||||
id = 299L,
|
||||
room = room,
|
||||
participant = creatorParticipant,
|
||||
text = "newer",
|
||||
createdAt = LocalDateTime.of(2026, 5, 13, 10, 1)
|
||||
)
|
||||
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant)
|
||||
Mockito.`when`(
|
||||
messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
||||
room,
|
||||
300L,
|
||||
PageRequest.of(0, 20)
|
||||
)
|
||||
).thenReturn(listOf(newerMessage, olderMessage))
|
||||
Mockito.`when`(messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, 298L)).thenReturn(true)
|
||||
|
||||
val response = service.getMessages(user, roomId = 10L, cursor = 300L)
|
||||
|
||||
assertEquals(listOf(298L, 299L), response.messages.map { it.messageId })
|
||||
assertEquals(listOf("older", "newer"), response.messages.map { it.textMessage })
|
||||
assertTrue(response.hasMore)
|
||||
assertEquals(298L, response.nextCursor)
|
||||
Mockito.verify(messageRepository).findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
||||
room,
|
||||
300L,
|
||||
PageRequest.of(0, 20)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실시간 연결 해제는 참여자를 제거하지 않고 presence만 해제한다")
|
||||
fun shouldDisconnectRealtimeWithoutLeavingRoom() {
|
||||
val user = member(1L, "user")
|
||||
val room = room(10L)
|
||||
val participant = participant(100L, room, user)
|
||||
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(participant)
|
||||
|
||||
service.disconnectRealtime(user, 10L)
|
||||
|
||||
Mockito.verify(realtimeService).disconnect(10L, 1L)
|
||||
Mockito.verify(participantRepository, Mockito.never()).save(Mockito.any(UserCreatorChatParticipant::class.java))
|
||||
}
|
||||
|
||||
private fun member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id }
|
||||
|
||||
private fun anyMessageItem(): UserCreatorChatMessageItemDto {
|
||||
return Mockito.any(UserCreatorChatMessageItemDto::class.java) ?: UserCreatorChatMessageItemDto(
|
||||
messageId = 0L,
|
||||
messageType = "TEXT",
|
||||
mine = false,
|
||||
createdAt = 0L,
|
||||
textMessage = null,
|
||||
voiceMessageUrl = null,
|
||||
senderId = 0L,
|
||||
senderNickname = "",
|
||||
senderProfileImageUrl = ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun room(id: Long) = UserCreatorChatRoom().apply {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
private fun participant(
|
||||
id: Long,
|
||||
room: UserCreatorChatRoom,
|
||||
member: Member
|
||||
) = UserCreatorChatParticipant(chatRoom = room, member = member).apply { this.id = id }
|
||||
|
||||
private fun textMessage(
|
||||
id: Long,
|
||||
room: UserCreatorChatRoom,
|
||||
participant: UserCreatorChatParticipant,
|
||||
text: String,
|
||||
createdAt: LocalDateTime
|
||||
) = UserCreatorChatMessage(
|
||||
chatRoom = room,
|
||||
participant = participant,
|
||||
messageType = UserCreatorChatMessageType.TEXT,
|
||||
textMessage = text
|
||||
).apply {
|
||||
this.id = id
|
||||
this.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user