feat(chat): 채팅방 초기화 API 추가 및 세션 종료 실패 시 롤백 처리

- /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회 재시도 후 실패 시 예외 전파 가능
- 채팅방 초기화 흐름에서는 외부 세션 종료 실패 시 예외를 던져 트랜잭션 롤백되도록 처리
This commit is contained in:
Klaus 2025-08-27 17:16:18 +09:00
parent 258943535c
commit 42ed4692af
8 changed files with 92 additions and 8 deletions

View File

@ -75,6 +75,7 @@ class CanService(private val repository: CanRepository) {
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
} }
val createdAt = it.createdAt!! val createdAt = it.createdAt!!

View File

@ -113,6 +113,9 @@ class CanPaymentService(
} else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) {
// 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속
useCan.member = member useCan.member = member
} else if (canUsage == CanUsage.CHAT_ROOM_RESET) {
// 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속
useCan.member = member
} else { } else {
throw SodaException("잘못된 요청입니다.") throw SodaException("잘못된 요청입니다.")
} }

View File

@ -12,5 +12,6 @@ enum class CanUsage {
AUDITION_VOTE, AUDITION_VOTE,
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
} }

View File

@ -72,4 +72,11 @@ class ChatQuotaService(
quota.remainingPaid += addPaid quota.remainingPaid += addPaid
quota.nextRechargeAt = null 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
}
} }

View File

@ -10,7 +10,7 @@ import javax.persistence.OneToMany
class ChatRoom( class ChatRoom(
val sessionId: String, val sessionId: String,
val title: String, val title: String,
val isActive: Boolean = true var isActive: Boolean = true
) : BaseEntity() { ) : BaseEntity() {
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
val messages: MutableList<ChatMessage> = mutableListOf() val messages: MutableList<ChatMessage> = mutableListOf()

View File

@ -1,6 +1,7 @@
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.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.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
@ -181,4 +182,23 @@ class ChatRoomController(
val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container)
ApiResponse.ok(result) 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)
}
} }

View File

@ -194,3 +194,10 @@ data class SendChatMessageResponse(
val totalRemaining: Int, val totalRemaining: Int,
val nextRechargeAtEpoch: Long? val nextRechargeAtEpoch: Long?
) )
/**
* 채팅방 초기화 요청 DTO
*/
data class ChatRoomResetRequest(
val container: String
)

View File

@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.room.service package kr.co.vividnext.sodalive.chat.room.service
import com.fasterxml.jackson.databind.ObjectMapper 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.CharacterImage
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
@ -422,7 +423,7 @@ class ChatRoomService(
} }
@Transactional @Transactional
fun leaveChatRoom(member: Member, chatRoomId: Long) { fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow { val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.") SodaException("채팅방을 찾을 수 없습니다.")
} }
@ -441,12 +442,13 @@ class ChatRoomService(
// 3) 내가 마지막 USER였다면 외부 세션 종료 // 3) 내가 마지막 USER였다면 외부 세션 종료
if (userCount == 0L) { 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 val maxAttempts = 3
var attempt = 0 var attempt = 0
while (attempt < maxAttempts) { while (attempt < maxAttempts) {
@ -489,9 +491,15 @@ class ChatRoomService(
log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message) log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message)
} }
} }
// 최종 실패 로그 (예외 미전파) // 최종 실패 처리
val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요."
if (throwOnFailure) {
log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts)
throw SodaException(message)
} else {
log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts)
} }
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse {
@ -774,4 +782,41 @@ class ChatRoomService(
} }
return null 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
}
} }