Compare commits
2 Commits
b3e7c00232
...
5a58fe9077
Author | SHA1 | Date | |
---|---|---|---|
5a58fe9077 | |||
12574dbe46 |
@@ -373,4 +373,53 @@ class CanPaymentService(
|
|||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun spendCanForChatMessage(
|
||||||
|
memberId: Long,
|
||||||
|
needCan: Int,
|
||||||
|
message: kr.co.vividnext.sodalive.chat.room.ChatMessage,
|
||||||
|
container: String
|
||||||
|
) {
|
||||||
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
|
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||||
|
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||||
|
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||||
|
throw SodaException(
|
||||||
|
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
||||||
|
"캔이 부족합니다. 충전 후 이용해 주세요."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||||
|
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val useCan = UseCan(
|
||||||
|
canUsage = CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
can = useChargeCan?.total ?: 0,
|
||||||
|
rewardCan = useRewardCan.total,
|
||||||
|
isSecret = false
|
||||||
|
)
|
||||||
|
useCan.member = member
|
||||||
|
useCan.chatMessage = message
|
||||||
|
// 이미지 메시지의 경우 이미지 연관도 함께 기록
|
||||||
|
message.characterImage?.let { img ->
|
||||||
|
useCan.characterImage = img
|
||||||
|
}
|
||||||
|
|
||||||
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.room.controller
|
package kr.co.vividnext.sodalive.chat.room.controller
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagePurchaseRequest
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest
|
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
|
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
|
||||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||||
@@ -159,4 +160,24 @@ class ChatRoomController(
|
|||||||
ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message))
|
ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유료 메시지 구매 API
|
||||||
|
* - 참여 여부 검증
|
||||||
|
* - 이미지 메시지의 경우 이미 보유 시 결제 없이 true 반환
|
||||||
|
* - 그 외 가격 검증 후 CanPaymentService 통해 결제 처리
|
||||||
|
*/
|
||||||
|
@PostMapping("/{chatRoomId}/messages/{messageId}/purchase")
|
||||||
|
fun purchaseMessage(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@PathVariable chatRoomId: Long,
|
||||||
|
@PathVariable messageId: Long,
|
||||||
|
@RequestBody request: ChatMessagePurchaseRequest
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
|
|
||||||
|
val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container)
|
||||||
|
ApiResponse.ok(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -130,6 +130,13 @@ data class SendChatMessageRequest(
|
|||||||
val message: String
|
val message: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유료 메시지 구매 요청 DTO
|
||||||
|
*/
|
||||||
|
data class ChatMessagePurchaseRequest(
|
||||||
|
val container: String
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 API 채팅 전송 응답 DTO
|
* 외부 API 채팅 전송 응답 DTO
|
||||||
*/
|
*/
|
||||||
|
@@ -47,6 +47,8 @@ class ChatRoomService(
|
|||||||
private val messageRepository: ChatMessageRepository,
|
private val messageRepository: ChatMessageRepository,
|
||||||
private val characterService: ChatCharacterService,
|
private val characterService: ChatCharacterService,
|
||||||
private val characterImageService: CharacterImageService,
|
private val characterImageService: CharacterImageService,
|
||||||
|
private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService,
|
||||||
|
private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront,
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
@@ -62,6 +64,46 @@ class ChatRoomService(
|
|||||||
) {
|
) {
|
||||||
private val log = LoggerFactory.getLogger(ChatRoomService::class.java)
|
private val log = LoggerFactory.getLogger(ChatRoomService::class.java)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto {
|
||||||
|
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||||
|
SodaException("채팅방을 찾을 수 없습니다.")
|
||||||
|
}
|
||||||
|
// 참여 여부 검증
|
||||||
|
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||||
|
?: throw SodaException("잘못된 접근입니다")
|
||||||
|
|
||||||
|
val message = messageRepository.findById(messageId).orElseThrow {
|
||||||
|
SodaException("메시지를 찾을 수 없습니다.")
|
||||||
|
}
|
||||||
|
if (!message.isActive) throw SodaException("비활성화된 메시지입니다.")
|
||||||
|
if (message.chatRoom.id != room.id) throw SodaException("잘못된 접근입니다")
|
||||||
|
|
||||||
|
val price = message.price ?: throw SodaException("구매할 수 없는 메시지입니다.")
|
||||||
|
if (price <= 0) throw SodaException("구매 가격이 잘못되었습니다.")
|
||||||
|
|
||||||
|
// 이미지 메시지인 경우: 이미 소유했다면 결제 생략하고 DTO 반환
|
||||||
|
if (message.messageType == ChatMessageType.IMAGE) {
|
||||||
|
val image = message.characterImage
|
||||||
|
if (image != null) {
|
||||||
|
val alreadyOwned = characterImageService.isOwnedImageByMember(image.id!!, member.id!!)
|
||||||
|
if (alreadyOwned) {
|
||||||
|
return toChatMessageItemDto(message, member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결제 진행 및 UseCan 기록 (이미지 메시지면 chatMessage + characterImage 동시 기록됨)
|
||||||
|
canPaymentService.spendCanForChatMessage(
|
||||||
|
memberId = member.id!!,
|
||||||
|
needCan = price,
|
||||||
|
message = message,
|
||||||
|
container = container
|
||||||
|
)
|
||||||
|
// 결제 완료 후 접근 가능 상태로 DTO 반환
|
||||||
|
return toChatMessageItemDto(message, member, forceHasAccess = true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 채팅방 생성 또는 조회
|
* 채팅방 생성 또는 조회
|
||||||
*
|
*
|
||||||
@@ -291,40 +333,7 @@ class ChatRoomService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val messagesAsc = fetched.sortedBy { it.createdAt }
|
val messagesAsc = fetched.sortedBy { it.createdAt }
|
||||||
val items = messagesAsc.map { msg ->
|
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
|
||||||
val sender = msg.participant
|
|
||||||
val profilePath = when (sender.participantType) {
|
|
||||||
ParticipantType.USER -> sender.member?.profileImage
|
|
||||||
ParticipantType.CHARACTER -> sender.character?.imagePath
|
|
||||||
}
|
|
||||||
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 = senderImageUrl,
|
|
||||||
mine = sender.member?.id == member.id,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChatRoomEnterResponse(
|
return ChatRoomEnterResponse(
|
||||||
roomId = room.id!!,
|
roomId = room.id!!,
|
||||||
@@ -471,40 +480,7 @@ class ChatRoomService(
|
|||||||
// createdAt 오름차순으로 정렬하여 반환
|
// createdAt 오름차순으로 정렬하여 반환
|
||||||
val messagesAsc = fetched.sortedBy { it.createdAt }
|
val messagesAsc = fetched.sortedBy { it.createdAt }
|
||||||
|
|
||||||
val items = messagesAsc.map { msg ->
|
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
|
||||||
val sender = msg.participant
|
|
||||||
val profilePath = when (sender.participantType) {
|
|
||||||
ParticipantType.USER -> sender.member?.profileImage
|
|
||||||
ParticipantType.CHARACTER -> sender.character?.imagePath
|
|
||||||
}
|
|
||||||
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 = senderImageUrl,
|
|
||||||
mine = sender.member?.id == member.id,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChatMessagesPageResponse(
|
return ChatMessagesPageResponse(
|
||||||
messages = items,
|
messages = items,
|
||||||
@@ -602,25 +578,68 @@ class ChatRoomService(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val imageDto = ChatMessageItemDto(
|
val imageDto = toChatMessageItemDto(imageMsg, member)
|
||||||
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, imageDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
return listOf(textDto)
|
return listOf(textDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun toChatMessageItemDto(
|
||||||
|
msg: ChatMessage,
|
||||||
|
member: Member,
|
||||||
|
forceHasAccess: Boolean = false
|
||||||
|
): ChatMessageItemDto {
|
||||||
|
val sender = msg.participant
|
||||||
|
val profilePath = when (sender.participantType) {
|
||||||
|
ParticipantType.USER -> sender.member?.profileImage
|
||||||
|
ParticipantType.CHARACTER -> sender.character?.imagePath
|
||||||
|
}
|
||||||
|
val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
|
||||||
|
val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L
|
||||||
|
val hasAccess = if (forceHasAccess) {
|
||||||
|
true
|
||||||
|
} else if (msg.messageType == ChatMessageType.IMAGE) {
|
||||||
|
if (msg.price == null) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
msg.characterImage?.id?.let {
|
||||||
|
characterImageService.isOwnedImageByMember(it, member.id!!)
|
||||||
|
} ?: true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val expirationMs = 5L * 60L * 1000L
|
||||||
|
val resolvedImageUrl: String? = if (msg.messageType == ChatMessageType.IMAGE) {
|
||||||
|
val path = if (hasAccess) {
|
||||||
|
msg.characterImage?.imagePath ?: msg.imagePath
|
||||||
|
} else {
|
||||||
|
msg.imagePath
|
||||||
|
}
|
||||||
|
path?.let { p ->
|
||||||
|
if (hasAccess) {
|
||||||
|
imageCloudFront.generateSignedURL(p, expirationMs)
|
||||||
|
} else {
|
||||||
|
"$imageHost/$p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return ChatMessageItemDto(
|
||||||
|
messageId = msg.id!!,
|
||||||
|
message = msg.message,
|
||||||
|
profileImageUrl = senderImageUrl,
|
||||||
|
mine = sender.member?.id == member.id,
|
||||||
|
createdAt = createdAtMillis,
|
||||||
|
messageType = msg.messageType.name,
|
||||||
|
imageUrl = resolvedImageUrl,
|
||||||
|
price = msg.price,
|
||||||
|
hasAccess = hasAccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun callExternalApiForChatSendWithRetry(
|
private fun callExternalApiForChatSendWithRetry(
|
||||||
userId: String,
|
userId: String,
|
||||||
characterUUID: String,
|
characterUUID: String,
|
||||||
|
Reference in New Issue
Block a user