캐릭터 챗봇 #338
| @@ -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,7 @@ 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, | ||||||
|  |  | ||||||
|     @Value("\${weraser.api-key}") |     @Value("\${weraser.api-key}") | ||||||
|     private val apiKey: String, |     private val apiKey: String, | ||||||
| @@ -62,6 +63,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) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 채팅방 생성 또는 조회 |      * 채팅방 생성 또는 조회 | ||||||
|      * |      * | ||||||
| @@ -621,6 +662,44 @@ class ChatRoomService( | |||||||
|         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 | ||||||
|  |         } | ||||||
|  |         return 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 = hasAccess | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private fun callExternalApiForChatSendWithRetry( |     private fun callExternalApiForChatSendWithRetry( | ||||||
|         userId: String, |         userId: String, | ||||||
|         characterUUID: String, |         characterUUID: String, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user