diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt index 1013a3e..fb1c35e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt @@ -1,14 +1,18 @@ package kr.co.vividnext.sodalive.chat.room +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage 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 ChatMessage( + // 텍스트 메시지 본문. 현재는 NOT NULL 유지. IMAGE 타입 등 비텍스트 메시지는 빈 문자열("") 저장 방침. @Column(columnDefinition = "TEXT", nullable = false) val message: String, @@ -20,5 +24,23 @@ class ChatMessage( @JoinColumn(name = "participant_id", nullable = false) val participant: ChatParticipant, - val isActive: Boolean = true + val isActive: Boolean = true, + + @Enumerated(EnumType.STRING) + @Column(name = "message_type", nullable = false) + val messageType: ChatMessageType = ChatMessageType.TEXT, + + // 미리 저장된 캐릭터 이미지 참조 (옵션) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_image_id", nullable = true) + val characterImage: CharacterImage? = null, + + // 이미지 정적 경로 스냅샷 (옵션) + @Column(name = "image_path", nullable = true, length = 1024) + val imagePath: String? = null, + + // 메시지 가격 (옵션). 제공되는 경우 1 이상이어야 함. + // Bean Validation 사용 시 @field:Min(1) 추가 고려. + @Column(name = "price", nullable = true) + val price: Int? = null ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessageType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessageType.kt new file mode 100644 index 0000000..0a1756a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessageType.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.chat.room + +/** + * 채팅 메시지 타입 + * - TEXT: 일반 텍스트 메시지 + * - IMAGE: 이미지 메시지(캐릭터 이미지 등) + * + * 유의: 유료 여부는 별도 price 필드로 표현합니다. + */ +enum class ChatMessageType { + TEXT, + IMAGE +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index e7691b6..267e67f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -39,7 +39,11 @@ data class ChatMessageItemDto( val message: String, val profileImageUrl: String, val mine: Boolean, - val createdAt: Long + val createdAt: Long, + val messageType: String, + val imageUrl: String?, + val price: Int?, + val hasAccess: Boolean ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 04c9582..2521107 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -1,8 +1,11 @@ package kr.co.vividnext.sodalive.chat.room.service import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.room.ChatMessage +import kr.co.vividnext.sodalive.chat.room.ChatMessageType import kr.co.vividnext.sodalive.chat.room.ChatParticipant import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType @@ -34,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.client.RestTemplate import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneId import java.util.UUID @Service @@ -42,6 +46,7 @@ class ChatRoomService( private val participantRepository: ChatParticipantRepository, private val messageRepository: ChatMessageRepository, private val characterService: ChatCharacterService, + private val characterImageService: CharacterImageService, @Value("\${weraser.api-key}") private val apiKey: String, @@ -293,14 +298,31 @@ class ChatRoomService( ParticipantType.CHARACTER -> sender.character?.imagePath } val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" - val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L ChatMessageItemDto( messageId = msg.id!!, message = msg.message, profileImageUrl = senderImageUrl, mine = sender.member?.id == member.id, - createdAt = createdAtMillis + createdAt = createdAtMillis, + messageType = msg.messageType.name, + imageUrl = msg.imagePath?.let { "$imageHost/$it" }, + price = msg.price, + hasAccess = if (msg.messageType == ChatMessageType.IMAGE) { + if (msg.price == null) { + true + } else { + msg.characterImage?.id?.let { + characterImageService.isOwnedImageByMember( + it, + member.id!! + ) + } ?: true + } + } else { + true + } ) } @@ -455,15 +477,32 @@ class ChatRoomService( ParticipantType.USER -> sender.member?.profileImage ParticipantType.CHARACTER -> sender.character?.imagePath } - val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" - val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" + val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L ChatMessageItemDto( messageId = msg.id!!, message = msg.message, - profileImageUrl = imageUrl, + profileImageUrl = senderImageUrl, mine = sender.member?.id == member.id, - createdAt = createdAtMillis + createdAt = createdAtMillis, + messageType = msg.messageType.name, + imageUrl = msg.imagePath?.let { "$imageHost/$it" }, + price = msg.price, + hasAccess = if (msg.messageType == ChatMessageType.IMAGE) { + if (msg.price == null) { + true + } else { + msg.characterImage?.id?.let { + characterImageService.isOwnedImageByMember( + it, + member.id!! + ) + } ?: true + } + } else { + true + } ) } @@ -509,30 +548,77 @@ class ChatRoomService( ) messageRepository.save(myMsgEntity) - // 7) 캐릭터 메시지 저장 - val characterMsgEntity = ChatMessage( - message = characterReply, - chatRoom = room, - participant = characterParticipant, - isActive = true + // 7) 캐릭터 텍스트 메시지 항상 저장 + val characterTextMsg = messageRepository.save( + ChatMessage( + message = characterReply, + chatRoom = room, + participant = characterParticipant, + isActive = true + ) ) - val savedCharacterMsg = messageRepository.save(characterMsgEntity) - // 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소) + // 응답 프로필 이미지 URL 공통 구성 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, + val senderImageUrl = "$imageHost/$defaultPath" + + val textDto = ChatMessageItemDto( + messageId = characterTextMsg.id!!, + message = characterTextMsg.message, + profileImageUrl = senderImageUrl, mine = false, - createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant() + createdAt = characterTextMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant() ?.toEpochMilli() - ?: 0L + ?: 0L, + messageType = ChatMessageType.TEXT.name, + imageUrl = null, + price = null, + hasAccess = true ) - return listOf(dto) + // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) + val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) + if (matchedImage != null) { + val owned = characterImageService.isOwnedImageByMember(matchedImage.id!!, member.id!!) + val priceInt: Int? = if (owned) { + null + } else { + val p = matchedImage.messagePriceCan + if (p <= 0L) null else if (p > Int.MAX_VALUE) Int.MAX_VALUE else p.toInt() + } + // 보유하지 않은 경우 블러 이미지로 전송 + val snapshotPath = if (owned) matchedImage.imagePath else matchedImage.blurImagePath + val imageMsg = messageRepository.save( + ChatMessage( + message = "", + chatRoom = room, + participant = characterParticipant, + isActive = true, + messageType = ChatMessageType.IMAGE, + characterImage = matchedImage, + imagePath = snapshotPath, + price = priceInt + ) + ) + + val imageDto = ChatMessageItemDto( + messageId = imageMsg.id!!, + message = imageMsg.message, + profileImageUrl = senderImageUrl, + mine = false, + createdAt = imageMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant() + ?.toEpochMilli() + ?: 0L, + messageType = ChatMessageType.IMAGE.name, + imageUrl = imageMsg.imagePath?.let { "$imageHost/$it" }, + price = imageMsg.price, + hasAccess = owned || imageMsg.price == null + ) + return listOf(textDto, imageDto) + } + + return listOf(textDto) } private fun callExternalApiForChatSendWithRetry( @@ -603,4 +689,18 @@ class ChatRoomService( } return characterContent } + + private fun findTriggeredCharacterImage(characterId: Long, replyText: String): CharacterImage? { + val text = replyText.lowercase() + val images: List = characterImageService.listActiveByCharacter(characterId) + for (img in images) { + val triggers = img.triggerMappings + .map { it.tag.word.trim().lowercase() } + .filter { it.isNotBlank() } + if (triggers.isEmpty()) continue + val allIncluded = triggers.all { t -> text.contains(t) } + if (allIncluded) return img + } + return null + } }