From 42ed4692af53b92969a55c1a630cabca1335ea80 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 27 Aug 2025 17:16:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20API=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EC=A2=85=EB=A3=8C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EB=A1=A4=EB=B0=B1=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/chat/room/{chatRoomId}/reset POST 엔드포인트 추가 - 초기화 절차: 30캔 결제 → 기존 방 나가기 → 동일 캐릭터로 새 방 생성 → 응답 반환 - 결제 시 CanUsage.CHAT_ROOM_RESET 신규 항목 사용(본인 귀속) - ChatQuotaService.resetFreeToDefault 추가 및 초기화 성공 시 무료 10회로 리셋(nextRechargeAt 초기화) - 사용내역 타이틀에 "캐릭터 톡 초기화" 노출(CanService) - ChatRoomResetRequest DTO(container 포함) 추가 - leaveChatRoom에 throwOnSessionEndFailure 옵션 추가(기본 false 유지) - endExternalSession에 throwOnFailure 옵션 추가: 최대 3회 재시도 후 실패 시 예외 전파 가능 - 채팅방 초기화 흐름에서는 외부 세션 종료 실패 시 예외를 던져 트랜잭션 롤백되도록 처리 --- .../co/vividnext/sodalive/can/CanService.kt | 1 + .../sodalive/can/payment/CanPaymentService.kt | 3 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 3 +- .../sodalive/chat/quota/ChatQuotaService.kt | 7 +++ .../vividnext/sodalive/chat/room/ChatRoom.kt | 2 +- .../room/controller/ChatRoomController.kt | 20 +++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 7 +++ .../chat/room/service/ChatRoomService.kt | 57 +++++++++++++++++-- 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 55b6841..555cc6e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -75,6 +75,7 @@ class CanService(private val repository: CanRepository) { CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" + CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화" } val createdAt = it.createdAt!! 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 c9d2498..ce57b14 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 @@ -113,6 +113,9 @@ class CanPaymentService( } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 useCan.member = member + } else if (canUsage == CanUsage.CHAT_ROOM_RESET) { + // 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속 + useCan.member = member } else { throw SodaException("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 4f06828..44bcbd5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -12,5 +12,6 @@ enum class CanUsage { AUDITION_VOTE, CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 - CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 + CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전 + CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt index 80b6ccf..d62c822 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -72,4 +72,11 @@ class ChatQuotaService( quota.remainingPaid += addPaid quota.nextRechargeAt = null } + + @Transactional + fun resetFreeToDefault(memberId: Long) { + val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) + quota.remainingFree = FREE_BUCKET + quota.nextRechargeAt = null + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt index ff8e9d0..65caa00 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt @@ -10,7 +10,7 @@ import javax.persistence.OneToMany class ChatRoom( val sessionId: String, val title: String, - val isActive: Boolean = true + var isActive: Boolean = true ) : BaseEntity() { @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) val messages: MutableList = mutableListOf() 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 aa0f1f1..7434207 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,6 +1,7 @@ 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.ChatRoomResetRequest 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 @@ -181,4 +182,23 @@ class ChatRoomController( val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) ApiResponse.ok(result) } + + /** + * 채팅방 초기화 API + * - 로그인 및 본인인증 확인 + * - 내가 참여 중인 AI 캐릭터 채팅방인지 확인 + * - 30캔 결제 → 현재 채팅방 나가기 → 동일 캐릭터와 새 채팅방 생성 → 생성된 채팅방 데이터 반환 + */ + @PostMapping("/{chatRoomId}/reset") + fun resetChatRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @RequestBody request: ChatRoomResetRequest + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container) + ApiResponse.ok(response) + } } 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 b09f230..df80d89 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 @@ -194,3 +194,10 @@ data class SendChatMessageResponse( val totalRemaining: Int, val nextRechargeAtEpoch: Long? ) + +/** + * 채팅방 초기화 요청 DTO + */ +data class ChatRoomResetRequest( + val container: String +) 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 50bd17b..8458fdb 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 @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.room.service import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.chat.character.image.CharacterImage import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService @@ -422,7 +423,7 @@ class ChatRoomService( } @Transactional - fun leaveChatRoom(member: Member, chatRoomId: Long) { + fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) { val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") } @@ -441,12 +442,13 @@ class ChatRoomService( // 3) 내가 마지막 USER였다면 외부 세션 종료 if (userCount == 0L) { - endExternalSession(room.sessionId) + endExternalSession(room.sessionId, throwOnFailure = throwOnSessionEndFailure) + room.isActive = false } } - private fun endExternalSession(sessionId: String) { - // 사용자 흐름을 방해하지 않기 위해 실패 시 예외를 던지지 않고 내부 재시도 후 로그만 남깁니다. + private fun endExternalSession(sessionId: String, throwOnFailure: Boolean = false) { + // 기본 동작: 내부 재시도. throwOnFailure=true일 때는 최종 실패 시 예외 전파. val maxAttempts = 3 var attempt = 0 while (attempt < maxAttempts) { @@ -489,8 +491,14 @@ class ChatRoomService( log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message) } } - // 최종 실패 로그 (예외 미전파) - log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) + // 최종 실패 처리 + val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요." + if (throwOnFailure) { + log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts) + throw SodaException(message) + } else { + log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) + } } @Transactional(readOnly = true) @@ -774,4 +782,41 @@ class ChatRoomService( } return null } + + @Transactional + fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse { + // 0) 방 존재 및 내 참여 여부 확인 + val room = chatRoomRepository.findById(chatRoomId).orElseThrow { + SodaException("채팅방을 찾을 수 없습니다.") + } + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") + + // 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인) + val characterParticipant = participantRepository + .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + val character = characterParticipant.character + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + + // 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용) + canPaymentService.spendCan( + memberId = member.id!!, + needCan = 30, + canUsage = CanUsage.CHAT_ROOM_RESET, + container = container + ) + + // 3) 현재 채팅방 나가기 (세션 종료 실패 시 롤백되도록 설정) + leaveChatRoom(member, chatRoomId, true) + + // 4) 동일한 캐릭터와 새로운 채팅방 생성 + val created = createOrGetChatRoom(member, character.id!!) + + // 5) 신규 채팅방 생성 성공 시 무료 채팅 횟수 10으로 설정 + chatQuotaService.resetFreeToDefault(member.id!!) + + // 6) 생성된 채팅방 데이터 반환 + return created + } }