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.
}
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.
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.

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.isAfter(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_SESSION_NOT_ACTIVE(HttpStatus.CONFLICT, "MENTOR_409_02", "활성 상태의 세션에서만 질문할 수 있습니다."),
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_03", "활성 상태의 세션에서만 질문할 수 있습니다."),
MENTOR_QUESTION_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "MENTOR_429_01", "질문 가능 횟수를 초과했습니다."),
MENTOR_RESPONSE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MENTOR_500_01", "AI 멘토 답변 생성에 실패했습니다."),
MENTOR_MESSAGE_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MENTOR_500_02", "멘토 대화 저장에 실패했습니다."),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
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.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface MentorChatSessionRepository extends JpaRepository<MentorChatSession, Long> {

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select session from MentorChatSession session where session.analysis.id = :analysisId")
Optional<MentorChatSession> findByAnalysisIdForUpdate(@Param("analysisId") Long analysisId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select session from MentorChatSession session where session.id = :sessionId")
Optional<MentorChatSession> findByIdForUpdate(@Param("sessionId") Long sessionId);

@Modifying(clearAutomatically = true)
@Query("delete from MentorChatSession mcs where mcs.user.userId = :userId")
void deleteAllByUserId(@Param("userId") Long userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.mr.domain.mentor.entity.MentorMessage;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -12,6 +11,8 @@ public interface MentorMessageRepository extends JpaRepository<MentorMessage, Lo

List<MentorMessage> findByMentorChatSessionAnalysisIdOrderByCreatedAtAscIdAsc(Long analysisId);

List<MentorMessage> findTop10ByMentorChatSessionAnalysisIdOrderByCreatedAtDescIdDesc(Long analysisId);

@Modifying(clearAutomatically = true)
@Query("delete from MentorMessage mm where mm.mentorChatSession.user.userId = :userId")
void deleteAllByUserId(@Param("userId") Long userId);
Expand Down
Loading
Loading