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:
parent
692e060f6d
commit
b3e7c00232
|
@ -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(
|
||||||
|
ChatMessage(
|
||||||
message = characterReply,
|
message = characterReply,
|
||||||
chatRoom = room,
|
chatRoom = room,
|
||||||
participant = characterParticipant,
|
participant = characterParticipant,
|
||||||
isActive = true
|
isActive = true
|
||||||
)
|
)
|
||||||
val savedCharacterMsg = messageRepository.save(characterMsgEntity)
|
|
||||||
|
|
||||||
// 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소)
|
|
||||||
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,
|
|
||||||
mine = false,
|
|
||||||
createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()
|
|
||||||
?.toEpochMilli()
|
|
||||||
?: 0L
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return listOf(dto)
|
// 응답 프로필 이미지 URL 공통 구성
|
||||||
|
val profilePath = characterParticipant.character?.imagePath
|
||||||
|
val defaultPath = profilePath ?: "profile/default-profile.png"
|
||||||
|
val senderImageUrl = "$imageHost/$defaultPath"
|
||||||
|
|
||||||
|
val textDto = ChatMessageItemDto(
|
||||||
|
messageId = characterTextMsg.id!!,
|
||||||
|
message = characterTextMsg.message,
|
||||||
|
profileImageUrl = senderImageUrl,
|
||||||
|
mine = false,
|
||||||
|
createdAt = characterTextMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()
|
||||||
|
?.toEpochMilli()
|
||||||
|
?: 0L,
|
||||||
|
messageType = ChatMessageType.TEXT.name,
|
||||||
|
imageUrl = null,
|
||||||
|
price = null,
|
||||||
|
hasAccess = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue