Compare commits

...

2 Commits

Author SHA1 Message Date
5a58fe9077 feat(chat): 이미지 메시지 조회 시 CloudFront 서명 URL 적용 및 DTO 변환 로직 공통화
- 조회 가능한(보유/무료/결제완료) 이미지 메시지의 이미지 URL을 ImageContentCloudFront.generateSignedURL(만료 5분)로 생성
- 접근 불가(미보유, 유료 미구매) 이미지 메시지는 기존 공개 호스트 URL(블러/스냅샷 경로) 유지
- ChatRoomService에 ImageContentCloudFront를 주입하고, toChatMessageItemDto에서 이미지 URL/hasAccess 결정 로직 단일화
- enterChatRoom, getChatMessages, sendMessage 경로의 중복된 DTO 매핑 로직 제거
- purchaseMessage 결제 완료 시 forceHasAccess=true로 접근 가능 DTO 반환
2025-08-25 14:28:11 +09:00
12574dbe46 feat(chat-room, payment): 유료 메시지 구매 플로우 구현 및 결제 연동(이미지 보유 처리 포함)
- 채팅 유료 메시지 구매 API 추가: POST /api/chat/room/{chatRoomId}/messages/{messageId}/purchase
- ChatRoomService.purchaseMessage 구현: 참여/유효성/가격 검증, 이미지 메시지 보유 시 결제 생략, 결제 완료 시 ChatMessageItemDto 반환
- CanPaymentService.spendCanForChatMessage 추가: UseCan에 chatMessage(+이미지 메시지면 characterImage) 연동 저장 및 게이트웨이 별 정산 기록(setUseCanCalculate)
- Character Image 결제 경로에 정산 기록 호출 누락분 보강
- ChatMessageItemDto 변환 헬퍼(toChatMessageItemDto) 추가 및 접근권한(hasAccess) 계산 일원화
2025-08-25 14:01:10 +09:00
4 changed files with 177 additions and 81 deletions

View File

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

View File

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

View File

@@ -130,6 +130,13 @@ data class SendChatMessageRequest(
val message: String
)
/**
* 유료 메시지 구매 요청 DTO
*/
data class ChatMessagePurchaseRequest(
val container: String
)
/**
* 외부 API 채팅 전송 응답 DTO
*/

View File

@@ -47,6 +47,8 @@ class ChatRoomService(
private val messageRepository: ChatMessageRepository,
private val characterService: ChatCharacterService,
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}")
private val apiKey: String,
@@ -62,6 +64,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)
}
/**
* 채팅방 생성 또는 조회
*
@@ -291,40 +333,7 @@ class ChatRoomService(
}
val messagesAsc = fetched.sortedBy { it.createdAt }
val items = messagesAsc.map { msg ->
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
}
)
}
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
return ChatRoomEnterResponse(
roomId = room.id!!,
@@ -471,40 +480,7 @@ class ChatRoomService(
// createdAt 오름차순으로 정렬하여 반환
val messagesAsc = fetched.sortedBy { it.createdAt }
val items = messagesAsc.map { msg ->
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
}
)
}
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
return ChatMessagesPageResponse(
messages = items,
@@ -602,25 +578,68 @@ class ChatRoomService(
)
)
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
)
val imageDto = toChatMessageItemDto(imageMsg, member)
return listOf(textDto, imageDto)
}
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(
userId: String,
characterUUID: String,