From c9630cb25e48b74bfbcb74e383602ba1c88a65e4 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Wed, 29 Jul 2026 14:10:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Ask=20Chat=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=82=A8=EC=9D=80=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AskChatQuestionSendResponse에 remainingMessageCount를 추가하고, 답변 생성 성공 시 차감 이후 값과 실패 시 환불 이후 값을 반환하도록 개선 --- .../response/AskChatQuestionSendResponse.java | 3 +++ .../AskChatMessageCommandService.java | 12 ++++++++++++ .../api/AskChatSessionControllerTest.java | 2 ++ .../AskChatMessageCommandServiceTest.java | 17 ++++++++++++++++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java index 677e5a8c..c6ec7749 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java @@ -18,6 +18,9 @@ public record AskChatQuestionSendResponse( @Schema(description = "답변 생성 성공/실패 상태. 실패 시 프론트에서는 이 값을 기준으로 모달/토스트를 표시합니다.") AskChatAnswerGenerationResponse answerGeneration, + @Schema(description = "답변 생성 처리 이후 사용 가능한 남은 메시지 횟수. 성공 시에는 차감 이후 값, 실패 시에는 환불 이후 값입니다.", example = "8") + int remainingMessageCount, + @Schema(description = "AI가 제안한 후속 추천 질문. 생성 실패 시 빈 배열") List followUpQuestions ) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java index fbe6bbb2..6159db56 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java @@ -17,6 +17,7 @@ import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatMessageRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagDocumentRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSessionRepository; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatAnswerLlmClient; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.BadRequestException; @@ -48,6 +49,7 @@ public class AskChatMessageCommandService { private final AskChatAnswerLlmClient askChatAnswerLlmClient; private final AskChatAnswerProperties askChatAnswerProperties; private final AskChatTurnReservationService askChatTurnReservationService; + private final AskChatWalletRepository askChatWalletRepository; @Transactional public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, String content) { @@ -70,6 +72,7 @@ public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, Str AskChatMessage assistantMessage; AskChatAnswerGenerationResponse answerGeneration; + int remainingMessageCount; List followUpQuestions; try { AskChatAnswerGenerationResult generationResult = askChatAnswerLlmClient.generate(context); @@ -78,12 +81,14 @@ public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, Str saveMessageReferences(assistantMessage, generationResult); askChatTurnReservationService.confirm(turnReservation); session = completeAnsweredTurn(userId, session.getId()); + remainingMessageCount = getRemainingMessageCount(userId); answerGeneration = AskChatAnswerGenerationResponse.completed(); followUpQuestions = generationResult.answer().followUpQuestions(); } catch (AiServiceException e) { long generationDurationMs = elapsedMillis(generationStartedAt); saveFailedAssistantMessage(session, e, generationDurationMs); askChatTurnReservationService.refund(userId, session, turnReservation); + remainingMessageCount = getRemainingMessageCount(userId); assistantMessage = null; answerGeneration = AskChatAnswerGenerationResponse.failed( e.getErrorCode(), @@ -97,6 +102,7 @@ public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, Str AskChatMessageResponse.from(userMessage), assistantMessage == null ? null : AskChatMessageResponse.from(assistantMessage), answerGeneration, + remainingMessageCount, followUpQuestions ); } @@ -159,6 +165,12 @@ private AskChatSession getSession(Long userId, Long sessionId) { .orElseThrow(() -> new NotFoundException(ErrorCode.ASK_CHAT_SESSION_NOT_FOUND)); } + private int getRemainingMessageCount(Long userId) { + return askChatWalletRepository.findByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ASK_CHAT_WALLET_NOT_FOUND)) + .getTotalTurnBalance(); + } + private AskChatSession completeAnsweredTurn(Long userId, Long sessionId) { int updated = askChatSessionRepository.completeAnsweredTurn( sessionId, diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java index e8afc9dd..0ce6d3a8 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java @@ -98,6 +98,7 @@ void startSession_delegates_first_question_to_session_service() { ErrorCode.AI_RESPONSE_PARSE_FAILED, "generation failed" ), + 9, List.of() ); when(askChatSessionService.startSession(1L, "나는 어떤 사람이야?")).thenReturn(sendResponse); @@ -138,6 +139,7 @@ void sendQuestion_delegates_user_session_and_content_to_message_service() { ErrorCode.AI_RESPONSE_PARSE_FAILED, "generation failed" ), + 9, List.of() ); when(askChatMessageCommandService.sendQuestion(1L, 10L, "question")).thenReturn(sendResponse); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java index 9c1ad521..332a66c9 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java @@ -11,12 +11,15 @@ import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatRagDocument; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSession; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSessionStatus; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWallet; import com.devkor.ifive.nadab.domain.askchat.core.properties.AskChatAnswerProperties; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatMessageReferenceRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatMessageRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagDocumentRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSessionRepository; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatAnswerLlmClient; +import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.ConflictException; @@ -75,6 +78,9 @@ class AskChatMessageCommandServiceTest { @Mock private AskChatTurnReservationService askChatTurnReservationService; + @Mock + private AskChatWalletRepository askChatWalletRepository; + private AskChatMessageCommandService service; private AskChatAnswerProperties askChatAnswerProperties; @@ -91,7 +97,8 @@ void setUp() { askChatAnswerContextService, askChatAnswerLlmClient, askChatAnswerProperties, - askChatTurnReservationService + askChatTurnReservationService, + askChatWalletRepository ); } @@ -127,11 +134,13 @@ void sendQuestion_saves_user_message_to_requested_session() { eq(AskChatSessionService.MAX_TURN_COUNT), any(OffsetDateTime.class) )).thenReturn(1); + when(askChatWalletRepository.findByUserId(1L)).thenReturn(Optional.of(wallet(3, 5))); var response = service.sendQuestion(1L, 10L, " 나는 어떤 사람이야? "); assertThat(response.session().sessionId()).isEqualTo(10L); assertThat(response.session().answeredTurnCount()).isEqualTo(3); + assertThat(response.remainingMessageCount()).isEqualTo(8); assertThat(response.userMessage().role()).isEqualTo(AskChatMessageRole.USER); assertThat(response.userMessage().status()).isEqualTo(AskChatMessageStatus.COMPLETED); assertThat(response.userMessage().content()).isEqualTo("나는 어떤 사람이야?"); @@ -205,11 +214,13 @@ void sendQuestion_saves_failed_assistant_message_when_answer_generation_fails() when(askChatTurnReservationService.reserveTurn(1L, activeSession)).thenReturn(reservation); when(askChatAnswerLlmClient.generate(context)) .thenThrow(new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED)); + when(askChatWalletRepository.findByUserId(1L)).thenReturn(Optional.of(wallet(2, 7))); var response = service.sendQuestion(1L, 11L, "나는 어떤 사람이야?"); assertThat(response.userMessage().status()).isEqualTo(AskChatMessageStatus.COMPLETED); assertThat(response.assistantMessage()).isNull(); + assertThat(response.remainingMessageCount()).isEqualTo(9); assertThat(response.answerGeneration().success()).isFalse(); assertThat(response.answerGeneration().errorCode()).isEqualTo(ErrorCode.AI_RESPONSE_PARSE_FAILED.getCode()); assertThat(response.answerGeneration().message()).isEqualTo("답변 생성에 오류가 발생했어요. 다시 시도해주세요."); @@ -331,6 +342,10 @@ private AskChatTurnReservation reservation() { return new AskChatTurnReservation(1000L, -1, 0); } + private AskChatWallet wallet(int freeTurnBalance, int paidTurnBalance) { + return AskChatWallet.create(mock(User.class), freeTurnBalance, paidTurnBalance); + } + private AskChatSession session( Long id, AskChatSessionStatus status, From d5a62871ea204977b8c0c15df5a8f0df94cedd11 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Wed, 29 Jul 2026 14:16:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Ask=20Chat=20=EB=82=A8=EC=9D=80=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=88=98=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /ask-chat/turns/remaining에서 remainingMessageCount만 조회할 수 있도록 응답 DTO와 서비스/컨트롤러 계약을 추가 --- .../askchat/api/AskChatSessionController.java | 32 +++++++++++++++++++ .../AskChatRemainingMessageCountResponse.java | 10 ++++++ .../application/AskChatSessionService.java | 9 ++++++ .../api/AskChatSessionControllerTest.java | 18 +++++++++++ .../AskChatSessionServiceTest.java | 16 ++++++++++ 5 files changed, 85 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatRemainingMessageCountResponse.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java index 06741d74..c9ae2f0a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.request.AskChatSessionStartRequest; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatHomeResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatTurnChargeResponse; import com.devkor.ifive.nadab.domain.askchat.application.AskChatMessageCommandService; import com.devkor.ifive.nadab.domain.askchat.application.AskChatSessionService; @@ -158,6 +159,37 @@ public ResponseEntity> chargeTurns( return ApiResponseEntity.ok(response); } + @GetMapping("/turns/remaining") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "물어보기 남은 메시지 횟수 조회", + description = """ + 현재 사용자가 사용할 수 있는 물어보기 남은 메시지 횟수만 조회합니다.
+ 홈 전체 정보를 다시 조회하지 않고 질문 전송/충전 이후 카운터만 갱신할 때 사용할 수 있습니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "남은 메시지 횟수 조회 성공", + content = @Content(schema = @Schema(implementation = AskChatRemainingMessageCountResponse.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: ASK_CHAT_WALLET_NOT_FOUND - Ask Chat 대화권 지갑을 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity> getRemainingTurns( + @AuthenticationPrincipal UserPrincipal principal + ) { + AskChatRemainingMessageCountResponse response = + askChatSessionService.getRemainingMessageCount(principal.getId()); + return ApiResponseEntity.ok(response); + } + @PostMapping("/messages") @PreAuthorize("isAuthenticated()") @Operation( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatRemainingMessageCountResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatRemainingMessageCountResponse.java new file mode 100644 index 00000000..b6b70126 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatRemainingMessageCountResponse.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.askchat.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "물어보기 남은 메시지 횟수 응답") +public record AskChatRemainingMessageCountResponse( + @Schema(description = "사용 가능한 남은 메시지 횟수", example = "9") + int remainingMessageCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java index a5d1ae5b..a55f3bd7 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java @@ -2,6 +2,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatHomeResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSampleQuestionResponse; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSession; @@ -70,6 +71,14 @@ public AskChatQuestionSendResponse startSession(Long userId, String content) { return askChatMessageCommandService.sendQuestion(userId, session.getId(), content); } + @Transactional(readOnly = true) + public AskChatRemainingMessageCountResponse getRemainingMessageCount(Long userId) { + AskChatWallet askChatWallet = askChatWalletRepository.findByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ASK_CHAT_WALLET_NOT_FOUND)); + + return new AskChatRemainingMessageCountResponse(askChatWallet.getTotalTurnBalance()); + } + private void validateMinimumAnswerCount(Long userId) { if (answerEntryRepository.countByUserId(userId) < MIN_ANSWER_COUNT_TO_USE_ASK_CHAT) { throw new BadRequestException(ErrorCode.ASK_CHAT_NOT_ENOUGH_ANSWERS); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java index 0ce6d3a8..1804c58c 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java @@ -6,6 +6,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatHomeResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatMessageResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSampleQuestionResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSessionResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatTurnChargeResponse; @@ -176,6 +177,23 @@ void chargeTurns_delegates_to_wallet_charge_service() { verify(askChatWalletChargeService).chargeTurns(1L); } + @Test + void getRemainingTurns_returns_remaining_message_count_only() { + AskChatSessionController controller = controller(); + UserPrincipal principal = new UserPrincipal(1L); + AskChatRemainingMessageCountResponse remainingResponse = + new AskChatRemainingMessageCountResponse(9); + when(askChatSessionService.getRemainingMessageCount(1L)).thenReturn(remainingResponse); + + ResponseEntity> response = + controller.getRemainingTurns(principal); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData().remainingMessageCount()).isEqualTo(9); + verify(askChatSessionService).getRemainingMessageCount(1L); + } + private AskChatSessionController controller() { return new AskChatSessionController( askChatSessionService, diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java index 8d227271..0bad4036 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java @@ -146,6 +146,22 @@ void startSession_creates_new_session_and_sends_first_question() { verifyNoInteractions(answerEntryRepository); } + @Test + void getRemainingMessageCount_returns_total_turn_balance() { + User user = mock(User.class); + when(askChatWalletRepository.findByUserId(1L)) + .thenReturn(Optional.of(AskChatWallet.create(user, 2, 7))); + + var response = service.getRemainingMessageCount(1L); + + assertThat(response.remainingMessageCount()).isEqualTo(9); + verifyNoInteractions(answerEntryRepository); + verifyNoInteractions(userRepository); + verifyNoInteractions(userWalletRepository); + verifyNoInteractions(askChatSampleQuestionRepository); + verify(askChatSessionRepository, never()).save(any()); + } + @Test void startSession_rejects_missing_user_when_creating_session() { when(userRepository.findById(1L)).thenReturn(Optional.empty());