캐릭터 챗봇 #338
| @@ -373,4 +373,53 @@ class CanPaymentService( | ||||
|         setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_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 | ||||
|  | ||||
| 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.SendChatMessageRequest | ||||
| import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService | ||||
| @@ -159,4 +160,24 @@ class ChatRoomController( | ||||
|             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 | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 유료 메시지 구매 요청 DTO | ||||
|  */ | ||||
| data class ChatMessagePurchaseRequest( | ||||
|     val container: String | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 외부 API 채팅 전송 응답 DTO | ||||
|  */ | ||||
|   | ||||
| @@ -47,6 +47,7 @@ class ChatRoomService( | ||||
|     private val messageRepository: ChatMessageRepository, | ||||
|     private val characterService: ChatCharacterService, | ||||
|     private val characterImageService: CharacterImageService, | ||||
|     private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, | ||||
|  | ||||
|     @Value("\${weraser.api-key}") | ||||
|     private val apiKey: String, | ||||
| @@ -62,6 +63,46 @@ class ChatRoomService( | ||||
| ) { | ||||
|     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) | ||||
|     } | ||||
|  | ||||
|     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( | ||||
|         userId: String, | ||||
|         characterUUID: String, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user