캐릭터 챗봇 #338

Merged
klaus merged 119 commits from test into main 2025-09-10 06:08:47 +00:00
4 changed files with 163 additions and 24 deletions
Showing only changes of commit b3e7c00232 - Show all commits

View File

@ -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()

View File

@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.chat.room
/**
* 채팅 메시지 타입
* - TEXT: 일반 텍스트 메시지
* - IMAGE: 이미지 메시지(캐릭터 이미지 )
*
* 유의: 유료 여부는 별도 price 필드로 표현합니다.
*/
enum class ChatMessageType {
TEXT,
IMAGE
}

View File

@ -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
) )
/** /**

View File

@ -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
}
} }