feat(chat): 이미지/유료(PPV) 메시지 도입 — 엔티티·서비스·DTO 확장 및 트리거 전송
- ChatMessageType(TEXT/IMAGE) 도입 - ChatMessage에 messageType/characterImage/imagePath/price 추가 - ChatMessageItemDto에 messageType/imageUrl/price/hasAccess 추가 - 캐릭터 답변 로직 - 텍스트 메시지 항상 저장/전송 - 트리거 일치 시 이미지 메시지 추가 저장/전송 - 미보유 시 blur + price 스냅샷, 보유 시 원본 + price=null - enterChatRoom/getChatMessages 응답에 확장된 필드 매핑 및 hasAccess 계산 반영
This commit is contained in:
		| @@ -1,14 +1,18 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.room | package kr.co.vividnext.sodalive.chat.room | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.image.CharacterImage | ||||||
| import kr.co.vividnext.sodalive.common.BaseEntity | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
| import javax.persistence.Column | import javax.persistence.Column | ||||||
| import javax.persistence.Entity | import javax.persistence.Entity | ||||||
|  | import javax.persistence.EnumType | ||||||
|  | import javax.persistence.Enumerated | ||||||
| import javax.persistence.FetchType | import javax.persistence.FetchType | ||||||
| import javax.persistence.JoinColumn | import javax.persistence.JoinColumn | ||||||
| import javax.persistence.ManyToOne | import javax.persistence.ManyToOne | ||||||
|  |  | ||||||
| @Entity | @Entity | ||||||
| class ChatMessage( | class ChatMessage( | ||||||
|  |     // 텍스트 메시지 본문. 현재는 NOT NULL 유지. IMAGE 타입 등 비텍스트 메시지는 빈 문자열("") 저장 방침. | ||||||
|     @Column(columnDefinition = "TEXT", nullable = false) |     @Column(columnDefinition = "TEXT", nullable = false) | ||||||
|     val message: String, |     val message: String, | ||||||
|  |  | ||||||
| @@ -20,5 +24,23 @@ class ChatMessage( | |||||||
|     @JoinColumn(name = "participant_id", nullable = false) |     @JoinColumn(name = "participant_id", nullable = false) | ||||||
|     val participant: ChatParticipant, |     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() | ) : BaseEntity() | ||||||
|   | |||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.room | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 채팅 메시지 타입 | ||||||
|  |  * - TEXT: 일반 텍스트 메시지 | ||||||
|  |  * - IMAGE: 이미지 메시지(캐릭터 이미지 등) | ||||||
|  |  * | ||||||
|  |  * 유의: 유료 여부는 별도 price 필드로 표현합니다. | ||||||
|  |  */ | ||||||
|  | enum class ChatMessageType { | ||||||
|  |     TEXT, | ||||||
|  |     IMAGE | ||||||
|  | } | ||||||
| @@ -39,7 +39,11 @@ data class ChatMessageItemDto( | |||||||
|     val message: String, |     val message: String, | ||||||
|     val profileImageUrl: String, |     val profileImageUrl: String, | ||||||
|     val mine: Boolean, |     val mine: Boolean, | ||||||
|     val createdAt: Long |     val createdAt: Long, | ||||||
|  |     val messageType: String, | ||||||
|  |     val imageUrl: String?, | ||||||
|  |     val price: Int?, | ||||||
|  |     val hasAccess: Boolean | ||||||
| ) | ) | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
| @@ -1,8 +1,11 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.room.service | package kr.co.vividnext.sodalive.chat.room.service | ||||||
|  |  | ||||||
| import com.fasterxml.jackson.databind.ObjectMapper | 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.character.service.ChatCharacterService | ||||||
| import kr.co.vividnext.sodalive.chat.room.ChatMessage | 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.ChatParticipant | ||||||
| import kr.co.vividnext.sodalive.chat.room.ChatRoom | import kr.co.vividnext.sodalive.chat.room.ChatRoom | ||||||
| import kr.co.vividnext.sodalive.chat.room.ParticipantType | 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 org.springframework.web.client.RestTemplate | ||||||
| import java.time.Duration | import java.time.Duration | ||||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||||
|  | import java.time.ZoneId | ||||||
| import java.util.UUID | import java.util.UUID | ||||||
|  |  | ||||||
| @Service | @Service | ||||||
| @@ -42,6 +46,7 @@ class ChatRoomService( | |||||||
|     private val participantRepository: ChatParticipantRepository, |     private val participantRepository: ChatParticipantRepository, | ||||||
|     private val messageRepository: ChatMessageRepository, |     private val messageRepository: ChatMessageRepository, | ||||||
|     private val characterService: ChatCharacterService, |     private val characterService: ChatCharacterService, | ||||||
|  |     private val characterImageService: CharacterImageService, | ||||||
|  |  | ||||||
|     @Value("\${weraser.api-key}") |     @Value("\${weraser.api-key}") | ||||||
|     private val apiKey: String, |     private val apiKey: String, | ||||||
| @@ -293,14 +298,31 @@ class ChatRoomService( | |||||||
|                 ParticipantType.CHARACTER -> sender.character?.imagePath |                 ParticipantType.CHARACTER -> sender.character?.imagePath | ||||||
|             } |             } | ||||||
|             val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" |             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 |                 ?: 0L | ||||||
|             ChatMessageItemDto( |             ChatMessageItemDto( | ||||||
|                 messageId = msg.id!!, |                 messageId = msg.id!!, | ||||||
|                 message = msg.message, |                 message = msg.message, | ||||||
|                 profileImageUrl = senderImageUrl, |                 profileImageUrl = senderImageUrl, | ||||||
|                 mine = sender.member?.id == member.id, |                 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.USER -> sender.member?.profileImage | ||||||
|                 ParticipantType.CHARACTER -> sender.character?.imagePath |                 ParticipantType.CHARACTER -> sender.character?.imagePath | ||||||
|             } |             } | ||||||
|             val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" |             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 |                 ?: 0L | ||||||
|             ChatMessageItemDto( |             ChatMessageItemDto( | ||||||
|                 messageId = msg.id!!, |                 messageId = msg.id!!, | ||||||
|                 message = msg.message, |                 message = msg.message, | ||||||
|                 profileImageUrl = imageUrl, |                 profileImageUrl = senderImageUrl, | ||||||
|                 mine = sender.member?.id == member.id, |                 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) |         messageRepository.save(myMsgEntity) | ||||||
|  |  | ||||||
|         // 7) 캐릭터 메시지 저장 |         // 7) 캐릭터 텍스트 메시지 항상 저장 | ||||||
|         val characterMsgEntity = ChatMessage( |         val characterTextMsg = messageRepository.save( | ||||||
|             message = characterReply, |             ChatMessage( | ||||||
|             chatRoom = room, |                 message = characterReply, | ||||||
|             participant = characterParticipant, |                 chatRoom = room, | ||||||
|             isActive = true |                 participant = characterParticipant, | ||||||
|  |                 isActive = true | ||||||
|  |             ) | ||||||
|         ) |         ) | ||||||
|         val savedCharacterMsg = messageRepository.save(characterMsgEntity) |  | ||||||
|  |  | ||||||
|         // 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소) |         // 응답 프로필 이미지 URL 공통 구성 | ||||||
|         val profilePath = characterParticipant.character?.imagePath |         val profilePath = characterParticipant.character?.imagePath | ||||||
|         val defaultPath = profilePath ?: "profile/default-profile.png" |         val defaultPath = profilePath ?: "profile/default-profile.png" | ||||||
|         val imageUrl = "$imageHost/$defaultPath" |         val senderImageUrl = "$imageHost/$defaultPath" | ||||||
|         val dto = ChatMessageItemDto( |  | ||||||
|             messageId = savedCharacterMsg.id!!, |         val textDto = ChatMessageItemDto( | ||||||
|             message = savedCharacterMsg.message, |             messageId = characterTextMsg.id!!, | ||||||
|             profileImageUrl = imageUrl, |             message = characterTextMsg.message, | ||||||
|  |             profileImageUrl = senderImageUrl, | ||||||
|             mine = false, |             mine = false, | ||||||
|             createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant() |             createdAt = characterTextMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant() | ||||||
|                 ?.toEpochMilli() |                 ?.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( |     private fun callExternalApiForChatSendWithRetry( | ||||||
| @@ -603,4 +689,18 @@ class ChatRoomService( | |||||||
|         } |         } | ||||||
|         return characterContent |         return characterContent | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private fun findTriggeredCharacterImage(characterId: Long, replyText: String): CharacterImage? { | ||||||
|  |         val text = replyText.lowercase() | ||||||
|  |         val images: List<CharacterImage> = 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 | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user