Skip to content
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package com.mr.domain.mentor.controller;

import com.mr.domain.mentor.dto.req.MentorQuestionRequestDTO;
import com.mr.domain.mentor.dto.res.MentorMessageHistoryResponseDTO;
import com.mr.domain.mentor.service.MentorQuestionService;
import com.mr.domain.mentor.service.MentorService;
import com.mr.domain.mentor.service.MentorStreamingService;
import com.mr.global.apipayload.ApiResponse;
import com.mr.global.security.SecurityUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequiredArgsConstructor
Expand All @@ -19,6 +26,8 @@
public class MentorController {

private final MentorService mentorService;
private final MentorQuestionService mentorQuestionService;
private final MentorStreamingService mentorStreamingService;

@GetMapping("/{analysisId}/mentor/messages")
@Operation(
Expand All @@ -31,4 +40,20 @@ public ApiResponse<MentorMessageHistoryResponseDTO> getMessageHistory(
Long userId = SecurityUtil.getCurrentUserId();
return ApiResponse.onSuccess(mentorService.getMessageHistory(userId, analysisId));
}

@PostMapping(
value = "/{analysisId}/mentor/messages",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
@Operation(summary = "AI 멘토 질문 전송 API", description = "질문을 저장하고 Gemini 답변을 SSE로 실시간 전송합니다.")
public SseEmitter sendQuestion(
@PathVariable Long analysisId,
@RequestBody MentorQuestionRequestDTO request
) {
Long userId = SecurityUtil.getCurrentUserId();
MentorQuestionService.PreparedQuestion prepared =
mentorQuestionService.prepare(userId, analysisId, request.content());
return mentorStreamingService.stream(prepared);
}
Comment thread
ownue marked this conversation as resolved.
Resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.mr.domain.mentor.dto.req;

public record MentorQuestionRequestDTO(String content) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.mr.domain.mentor.dto.res;

import com.fasterxml.jackson.databind.JsonNode;
import com.mr.domain.mentor.entity.MentorMessage;
import com.mr.domain.mentor.entity.enums.MessageRole;
import java.time.LocalDateTime;

public final class MentorStreamEventDTO {

private MentorStreamEventDTO() {
}

public record Start(Long analysisId, Long mentorChatSessionId, Message userMessage) {
}

public record Chunk(String content) {
}

public record Complete(Message assistantMessage) {
}

public record Error(String code, String message) {
}

public record Message(Long mentorMessageId, MessageRole role, JsonNode referencesJson,
String content, LocalDateTime createdAt) {

public static Message user(MentorMessage message) {
return new Message(message.getId(), message.getRole(), null, message.getContent(), message.getCreatedAt());
}

public static Message assistant(MentorMessage message, JsonNode referencesJson) {
return new Message(
message.getId(),
message.getRole(),
referencesJson,
message.getContent(),
message.getCreatedAt()
);
}
}
}
55 changes: 54 additions & 1 deletion src/main/java/com/mr/domain/mentor/entity/MentorChatSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
import com.mr.global.entity.BaseTimeEntity;
import jakarta.persistence.*;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
Expand Down Expand Up @@ -57,6 +60,12 @@ public class MentorChatSession extends BaseTimeEntity {
@Column(name = "last_message_at")
private LocalDateTime lastMessageAt;

@Column(name = "generation_token", length = 36)
Comment thread
ownue marked this conversation as resolved.
Resolved
private String generationToken;

@Column(name = "generation_started_at")
private LocalDateTime generationStartedAt;

@Column(name = "question_count", nullable = false)
private Integer questionCount;

Expand Down Expand Up @@ -108,12 +117,56 @@ public void updateLastMessageAt() {
this.lastMessageAt = LocalDateTime.now();
}

// TODO: 질문 횟수 3회 제한(MENTOR_429_01) 체크는 질문 전송 API 구현 시 추가
public void increaseQuestionCount() {
validateActive();
this.questionCount += 1;
}

public String startGenerating(Duration staleAfter) {
if (this.status == MentorChatStatus.GENERATING && !isStale(staleAfter)) {
throw new GeneralException(MentorErrorStatus.MENTOR_RESPONSE_IN_PROGRESS);
}
if (this.status != MentorChatStatus.ACTIVE && this.status != MentorChatStatus.GENERATING) {
throw new GeneralException(MentorErrorStatus.MENTOR_SESSION_NOT_ACTIVE);
}
if (this.questionCount >= 3) {
throw new GeneralException(MentorErrorStatus.MENTOR_QUESTION_LIMIT_EXCEEDED);
}
this.status = MentorChatStatus.GENERATING;
this.generationToken = UUID.randomUUID().toString();
this.generationStartedAt = LocalDateTime.now();
return this.generationToken;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

public void completeGenerating(String expectedToken) {
if (this.status != MentorChatStatus.GENERATING
|| !Objects.equals(this.generationToken, expectedToken)) {
throw new GeneralException(MentorErrorStatus.MENTOR_SESSION_NOT_ACTIVE);
}
this.questionCount += 1;
this.lastMessageAt = LocalDateTime.now();
this.status = MentorChatStatus.ACTIVE;
clearGeneration();
}

public void failGenerating(String expectedToken) {
if (this.status == MentorChatStatus.GENERATING
&& Objects.equals(this.generationToken, expectedToken)) {
this.status = MentorChatStatus.ACTIVE;
clearGeneration();
}
}

private boolean isStale(Duration staleAfter) {
return this.generationStartedAt == null
|| this.generationStartedAt.isBefore(LocalDateTime.now().minus(staleAfter));
}

private void clearGeneration() {
this.generationToken = null;
this.generationStartedAt = null;
}

private void validateActive() {
if (this.status != MentorChatStatus.ACTIVE) {
throw new GeneralException(MentorErrorStatus.MENTOR_SESSION_NOT_ACTIVE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

public enum MentorChatStatus {
ACTIVE,
GENERATING,
CLOSED,
DISABLED
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
@AllArgsConstructor
public enum MentorErrorStatus implements BaseCode {

// 400_01/02는 스펙상 질문 전송 검증용으로 예약됨
MENTOR_QUESTION_REQUIRED(HttpStatus.BAD_REQUEST, "MENTOR_400_01", "질문 내용을 입력해주세요."),
MENTOR_QUESTION_TOO_LONG(HttpStatus.BAD_REQUEST, "MENTOR_400_02", "질문은 500자 이하로 입력해주세요."),
MENTOR_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "MENTOR_400_03", "필수 정보가 누락되었습니다."),

MENTOR_ACCESS_DENIED(HttpStatus.FORBIDDEN, "MENTOR_403_01", "해당 대화에 접근할 수 없습니다."),
MENTOR_ANALYSIS_NOT_COMPLETED(HttpStatus.CONFLICT, "MENTOR_409_01", "분석 완료 후 질문할 수 있습니다."),
MENTOR_RESPONSE_IN_PROGRESS(HttpStatus.CONFLICT, "MENTOR_409_02", "이미 AI 멘토 답변을 생성하고 있습니다."),
MENTOR_SESSION_NOT_ACTIVE(HttpStatus.CONFLICT, "MENTOR_409_02", "활성 상태의 세션에서만 질문할 수 있습니다."),
Comment thread
ownue marked this conversation as resolved.
Resolved
MENTOR_QUESTION_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "MENTOR_429_01", "질문 가능 횟수를 초과했습니다."),
MENTOR_RESPONSE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500_02", "AI 멘토 답변 생성에 실패했습니다."),
Comment thread
ownue marked this conversation as resolved.
Resolved
MENTOR_MESSAGE_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MENTOR_500_02", "멘토 대화 저장에 실패했습니다."),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mr.domain.mentor.repository;

import com.mr.domain.mentor.entity.MentorChatSession;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface MentorChatSessionRepository extends JpaRepository<MentorChatSession, Long> {

Optional<MentorChatSession> findByAnalysisId(Long analysisId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select session from MentorChatSession session where session.id = :sessionId")
Optional<MentorChatSession> findByIdForUpdate(@Param("sessionId") Long sessionId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
public interface MentorMessageRepository extends JpaRepository<MentorMessage, Long> {

List<MentorMessage> findByMentorChatSessionAnalysisIdOrderByCreatedAtAscIdAsc(Long analysisId);

List<MentorMessage> findTop10ByMentorChatSessionAnalysisIdOrderByCreatedAtDescIdDesc(Long analysisId);
}
149 changes: 149 additions & 0 deletions src/main/java/com/mr/domain/mentor/service/MentorQuestionService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.mr.domain.mentor.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mr.domain.analysis.entity.Analysis;
import com.mr.domain.analysis.entity.AnalysisReport;
import com.mr.domain.analysis.entity.enums.AnalysisStatus;
import com.mr.domain.analysis.entity.enums.LlmStatus;
import com.mr.domain.analysis.exception.AnalysisErrorStatus;
import com.mr.domain.analysis.repository.AnalysisReportRepository;
import com.mr.domain.analysis.repository.AnalysisRepository;
import com.mr.domain.mentor.dto.res.MentorStreamEventDTO;
import com.mr.domain.mentor.entity.MentorChatSession;
import com.mr.domain.mentor.entity.MentorMessage;
import com.mr.domain.mentor.exception.MentorErrorStatus;
import com.mr.domain.mentor.repository.MentorChatSessionRepository;
import com.mr.domain.mentor.repository.MentorMessageRepository;
import com.mr.global.apipayload.exception.GeneralException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class MentorQuestionService {

private static final Duration STALE_GENERATION_AFTER = Duration.ofMinutes(2);
private static final String REFERENCES_JSON =
"{\"sourceFields\":[\"analysis.raw_result_json\",\"analysis_report.content\"]}";

private final AnalysisRepository analysisRepository;
private final AnalysisReportRepository analysisReportRepository;
private final MentorChatSessionRepository sessionRepository;
private final MentorMessageRepository messageRepository;
private final ObjectMapper objectMapper;

@Transactional
public PreparedQuestion prepare(Long userId, Long analysisId, String content) {
validateContent(content);

Analysis analysis = analysisRepository.findByIdForUpdate(analysisId)
.orElseThrow(() -> new GeneralException(AnalysisErrorStatus.ANALYSIS_NOT_FOUND));
validateOwner(analysis, userId);
if (analysis.getStatus() != AnalysisStatus.COMPLETED) {
throw new GeneralException(MentorErrorStatus.MENTOR_ANALYSIS_NOT_COMPLETED);
}

MentorChatSession session = sessionRepository.findByAnalysisId(analysisId)
.orElseGet(() -> sessionRepository.save(
MentorChatSession.createActive(analysis, analysis.getUser())));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

레포지토리에 findByIdForUpdate에는 비관적 락 정의 되어있는데 prepare()에는 일부러 findByAnalysisId 사용하신걸까요??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존에는 Analysis를 findByIdForUpdate()로 먼저 조회하고 있어 동일 분석에 대한 요청이 직렬화된다고 판단해 세션은 일반 조회를 사용했습니다. 분석 락은 세션이 아직 없는 최초 요청에서 중복 세션 생성을 막기 위해 유지할 필요가 있었습니당...

다만 리뷰 덕분에 기존 세션은 스트리밍 완료/실패 처리에서 findByIdForUpdate()로 직접 갱신되므로, prepare()에서도 동일 세션을 잠그지 않으면 세션 상태 변경이 동시에 진행될 여지가 있다는 걸 확인했습니다. 이에 분석 락은 유지하고, 세션 조회는 findByAnalysisIdForUpdate()로 변경해 생성 경쟁과 세션 상태 경쟁을 모두 방지하도록 수정했습니다!

감사합니닿!!

String generationToken = session.startGenerating(STALE_GENERATION_AFTER);

String prompt = buildPrompt(analysis, content.trim());
MentorMessage userMessage = messageRepository.saveAndFlush(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 질문 저장 및 questionCount 증가 시점의 불일치로 인한 정책 우회 가능성

현재 사용자 질문은 스트리밍 시작 전에 DB에 저장되지만, questionCount는 AI 답변이 정상 완료된 경우에만 증가하고 있습니다.

우려 사항

  1. 질문 제한 정책 우회 가능성 : 질문 전송 후 SSE 연결을 의도적으로 반복 종료하거나 AI 응답이 실패할 경우, 세션은 ACTIVE로 복구되지만 DB에 사용자 메시지만 쌓이고 questionCount는 증가하지 않을 것 같아요.
  2. 프롬프트 이력 오염: 실패/중단된 사용자 질문 메시지가 DB에 그대로 유지되어, 이후 정상 요청 시 AI 모델의 Context(대화 이력)에 포함될 수 있습니다.

개선안

  1. 요청 시점 차감: 사용자 질문을 저장하는 시점에 questionCount를 바로 증가시킵니다. (실패해도 질문 권한 소진) → 이 부분은 논의가 필요해 보일 듯 합니다.
  2. 실패 시 보정: 스트리밍 실패/중단 시 해당 사용자 메시지를 DB에서 삭제 or status = FAILED로 관리 → 이후 대화 이력 및 Count 계산에서 제외합니다.

개인적으로 실패 시 보정을 추가 도입하면 프롬프트 이력 관리도 함께 해결할 수 있어 더 적절한 방향이라고 생각하는데 은우님, 다른 팀원분들 의견도 궁금해요~

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 핵심 기능의 동작을 막는 문제는 아니므로 필수 수정 사항은 아닌 것 같습니다.
데이터 정합성과 질문 횟수 정책에 대해서는 팀원들과 별도로 논의한 뒤, 서브 이슈로 관리해도 좋을 것 같아요!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요! 좋은 리뷰 감사합니다. 서브 이슈로 관리하면 좋을 것 같아요!

저는 아래와 같은 로직 + 논의할 사안이라고 생각했는데 어떠실지요...

  1. 사용자 메시지에 PENDING, COMPLETED, FAILED 상태 추가
  2. 질문 저장 시 PENDING
  3. 답변 성공 시 질문을 COMPLETED로 변경하고 count 증가
  4. 실패 또는 중단 시 질문을 FAILED로 변경
  5. 프롬프트와 일반 대화 내역에서는 FAILED 질문 제외
  6. 실패 요청의 횟수 차감 여부만 논의 후 결정

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 좋습니다! 추후 자세히 정책 세우고 디벨롭하면 좋을 것 같아요.

MentorMessage.createUserMessage(session, content.trim()));
return new PreparedQuestion(
session.getId(),
generationToken,
prompt,
new MentorStreamEventDTO.Start(
analysisId,
session.getId(),
MentorStreamEventDTO.Message.user(userMessage)
)
);
}

@Transactional
public MentorStreamEventDTO.Complete complete(Long sessionId, String generationToken, String answer) {
MentorChatSession session = findSessionForUpdate(sessionId);
session.completeGenerating(generationToken);
MentorMessage assistantMessage = messageRepository.saveAndFlush(
MentorMessage.createAssistantMessage(session, REFERENCES_JSON, answer));
try {
return new MentorStreamEventDTO.Complete(MentorStreamEventDTO.Message.assistant(
assistantMessage,
objectMapper.readTree(REFERENCES_JSON)
));
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Invalid server-managed mentor references.", exception);
}
}

@Transactional
public void fail(Long sessionId, String generationToken) {
findSessionForUpdate(sessionId).failGenerating(generationToken);
}

private MentorChatSession findSessionForUpdate(Long sessionId) {
return sessionRepository.findByIdForUpdate(sessionId)
.orElseThrow(() -> new GeneralException(MentorErrorStatus.MENTOR_INVALID_REQUEST));
}

private String buildPrompt(Analysis analysis, String question) {
AnalysisReport report = analysisReportRepository
.findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(analysis.getId(), LlmStatus.SUCCESS)
.orElse(null);
List<MentorMessage> recentMessages = new ArrayList<>(
messageRepository.findTop10ByMentorChatSessionAnalysisIdOrderByCreatedAtDescIdDesc(
analysis.getId()));
Collections.reverse(recentMessages);

Map<String, Object> context = new LinkedHashMap<>();
context.put("analysis", analysis.getRawResultJson());
context.put("report", report == null ? null : report.getContent());
context.put("history", recentMessages.stream()
.map(message -> Map.of(
"role", message.getRole().name(),
"content", message.getContent()
))
.toList());
context.put("question", question);
try {
return objectMapper.writeValueAsString(context);
} catch (JsonProcessingException exception) {
throw new GeneralException(MentorErrorStatus.MENTOR_INVALID_REQUEST);
}
}

private void validateOwner(Analysis analysis, Long userId) {
if (!Objects.equals(analysis.getUser().getUserId(), userId)) {
throw new GeneralException(MentorErrorStatus.MENTOR_ACCESS_DENIED);
}
}

private void validateContent(String content) {
if (content == null || content.isBlank()) {
throw new GeneralException(MentorErrorStatus.MENTOR_QUESTION_REQUIRED);
}
if (content.trim().length() > 500) {
throw new GeneralException(MentorErrorStatus.MENTOR_QUESTION_TOO_LONG);
}
}

public record PreparedQuestion(
Long sessionId,
String generationToken,
String prompt,
MentorStreamEventDTO.Start startEvent
) {
}
}
Loading
Loading