From 12574dbe462bf12c4895ea2c8ad81dbc0d96cfec Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 25 Aug 2025 14:01:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-room,=20payment):=20=EC=9C=A0?= =?UTF-8?q?=EB=A3=8C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B5=AC=EB=A7=A4=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=97=B0=EB=8F=99(=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EC=9C=A0=20=EC=B2=98=EB=A6=AC=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅 유료 메시지 구매 API 추가: POST /api/chat/room/{chatRoomId}/messages/{messageId}/purchase - ChatRoomService.purchaseMessage 구현: 참여/유효성/가격 검증, 이미지 메시지 보유 시 결제 생략, 결제 완료 시 ChatMessageItemDto 반환 - CanPaymentService.spendCanForChatMessage 추가: UseCan에 chatMessage(+이미지 메시지면 characterImage) 연동 저장 및 게이트웨이 별 정산 기록(setUseCanCalculate) - Character Image 결제 경로에 정산 기록 호출 누락분 보강 - ChatMessageItemDto 변환 헬퍼(toChatMessageItemDto) 추가 및 접근권한(hasAccess) 계산 일원화 --- .../sodalive/can/payment/CanPaymentService.kt | 49 ++++++++++++ .../room/controller/ChatRoomController.kt | 21 +++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 7 ++ .../chat/room/service/ChatRoomService.kt | 79 +++++++++++++++++++ 4 files changed, 156 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 092a6e4..275a56d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -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) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 0a6f2a0..f5bd481 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -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) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 267e67f..8cf1ed4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -130,6 +130,13 @@ data class SendChatMessageRequest( val message: String ) +/** + * 유료 메시지 구매 요청 DTO + */ +data class ChatMessagePurchaseRequest( + val container: String +) + /** * 외부 API 채팅 전송 응답 DTO */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 2521107..55d58bb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -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,