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 | ||||
|  | ||||
| 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() | ||||
|   | ||||
| @@ -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 profileImageUrl: String, | ||||
|     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 | ||||
|  | ||||
| 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<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