Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.yd.vibecode.domain.chat.application.usecase;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse;
import com.yd.vibecode.domain.chat.domain.service.PromptMessageService;
import com.yd.vibecode.domain.chat.domain.service.PromptSessionService;
import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService;

import lombok.RequiredArgsConstructor;

/**
* AI 응답 사후 저장 전용 컴포넌트.
* AI HTTP 호출(무 트랜잭션) 이후의 DB 쓰기를 하나의 트랜잭션으로 묶어 원자성을 보장한다.
*/
@Component
@RequiredArgsConstructor
public class ChatResponsePersister {

private final PromptMessageService promptMessageService;
private final PromptSessionService promptSessionService;
private final ExamParticipantService examParticipantService;

@Transactional
public void persistAiResponse(Long sessionId, Long examId, Long participantId,
SendMessageResponse aiResponse) {
Integer tokenCount = aiResponse.tokenCount();

promptMessageService.create(
sessionId,
aiResponse.turnId(),
aiResponse.role() != null ? aiResponse.role() : "AI",
aiResponse.content() != null ? aiResponse.content() : "",
tokenCount != null ? tokenCount : 0,
null);

if (tokenCount != null && tokenCount > 0) {
promptSessionService.addTokens(sessionId, tokenCount);
examParticipantService.addTokenUsed(examId, participantId, tokenCount);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import java.util.Map;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.yd.vibecode.domain.chat.application.dto.request.AISendMessageRequest;
import com.yd.vibecode.domain.chat.domain.entity.PromptMessage;
import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest;
import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse;
import com.yd.vibecode.domain.chat.domain.entity.PromptSession;
Expand All @@ -23,8 +25,14 @@

/**
* 채팅 메시지 저장 UseCase
* - 사용자 메시지 저장
* - 사용자 메시지인 경우 AI 서버로 전송 및 AI 응답 저장
*
* 트랜잭션 경계:
* - 사전 저장(검증/세션/사용자 메시지): 각 서비스의 개별 트랜잭션에서 즉시 커밋
* - AI HTTP 호출: 트랜잭션 밖 (DB 커넥션 비점유) ← 커넥션 풀 고갈 방지
* - 사후 저장(AI 메시지/토큰): ChatResponsePersister 단일 트랜잭션
*
* 주의: execute()가 REQUIRED 트랜잭션을 시작/전파받으면 AI 응답(최대 90초)을 기다리는 동안
* DB 커넥션을 점유하여 풀이 고갈될 수 있으므로, NOT_SUPPORTED로 외부 트랜잭션을 명시적으로 중단한다.
*/
@Service
@RequiredArgsConstructor
Expand All @@ -34,131 +42,77 @@ public class SaveChatMessageUseCase {
private final PromptMessageService promptMessageService;
private final ExamParticipantService examParticipantService;
private final AIChatService aiChatService;
private final ChatResponsePersister chatResponsePersister;

@Transactional
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public SendMessageResponse execute(SaveChatMessageRequest request) {
// 1. ExamParticipant 조회하여 specId 가져오기
// 1. 참가자 검증 (읽기 트랜잭션 즉시 종료)
ExamParticipant examParticipant = examParticipantService.findByExamIdAndParticipantId(
request.examId(), request.participantId());

if (examParticipant == null) {
throw new RestApiException(GlobalErrorStatus._NOT_FOUND);
}
if (examParticipant.getSpecId() == null || examParticipant.getAssignedProblemId() == null) {
throw new RestApiException(ProblemErrorStatus.NO_ASSIGNED_PROBLEM);
}

// 2. 세션 가져오기 또는 생성 (별도 트랜잭션으로 즉시 커밋)
// AI 서버에서 세션을 조회할 수 있도록 별도 트랜잭션으로 처리하여 즉시 커밋
// 2. 세션 확보 (REQUIRES_NEW로 즉시 커밋 → AI 서버에서 조회 가능)
PromptSession session;
if (request.sessionId() != null) {
// sessionId가 제공되면 해당 세션 사용 (별도 트랜잭션으로 조회)
session = promptSessionService.findByIdWithNewTransaction(request.sessionId());
// 세션이 요청한 examId와 participantId와 일치하는지 검증
if (!session.getExamId().equals(request.examId()) ||
!session.getParticipantId().equals(request.participantId())) {
if (!session.getExamId().equals(request.examId())
|| !session.getParticipantId().equals(request.participantId())) {
throw new RestApiException(GlobalErrorStatus._NOT_FOUND);
}
promptSessionService.ensureSessionSpecId(session.getId(), examParticipant.getSpecId());
session = promptSessionService.findByIdWithNewTransaction(request.sessionId());
} else {
// sessionId가 없으면 examId와 participantId로 세션 조회/생성 (별도 트랜잭션으로 즉시 커밋)
session = promptSessionService.getOrCreateSessionWithNewTransaction(
request.examId(), request.participantId(), examParticipant.getSpecId());
}

// 3. 다음 turn 계산 (사용자가 보낸 turn은 무시하고 DB에서 자동 계산)
Integer nextTurn = promptMessageService.getNextTurn(session.getId());

// 4. 사용자 메시지 저장
promptMessageService.create(
session.getId(),
nextTurn,
request.role(),
request.content(),
request.tokenCount(),
request.meta()
);
// USER 메시지가 아니면 처리하지 않음 (FE는 USER만 전송)
if (!"USER".equalsIgnoreCase(request.role())) {
throw new RestApiException(GlobalErrorStatus._BAD_REQUEST);
}

// 4. 토큰 카운트가 있으면 세션에 누적
// 3. 사용자 메시지 저장 — turn 계산 + INSERT를 단일 트랜잭션으로 처리 (레이스 컨디션 방지)
PromptMessage savedUserMessage = promptMessageService.createWithNextTurn(
session.getId(), request.role(), request.content(),
request.tokenCount(), request.meta());
Integer nextTurn = savedUserMessage.getTurn();
if (request.tokenCount() != null && request.tokenCount() > 0) {
promptSessionService.addTokens(session.getId(), request.tokenCount());
}

// 5. 사용자 메시지인 경우 AI 서버로 전송
if ("USER".equalsIgnoreCase(request.role()) || "user".equalsIgnoreCase(request.role())) {
try {
// AI 요청 구성
Map<String, Object> context = new HashMap<>();
context.put("problemId", examParticipant.getAssignedProblemId()); // 문제 ID
context.put("specVersion", examParticipant.getSpecId()); // 스펙 ID

AISendMessageRequest aiRequest = AISendMessageRequest.builder()
.sessionId(session.getId())
.participantId(request.participantId())
.turnId(nextTurn) // 계산된 turn 사용
.role("USER")
.content(request.content())
.context(context)
.build();

// AI 호출 (Sync)
SendMessageResponse aiResponse = aiChatService.sendMessage(aiRequest);

if (aiResponse == null) {
throw new RuntimeException("AI 서버 응답이 null입니다.");
}

// AI 메시지 저장 및 토큰 업데이트
// AI 서버는 turn을 자동으로 증가시켜서 반환 (사용자 메시지 turn + 1)
Integer totalCount = aiResponse.totalCount();
Integer aiTurnId = aiResponse.turnId(); // AI 서버가 반환한 turn (사용자 turn + 1)
Integer tokenCount = aiResponse.tokenCount(); // 현재 턴 토큰 수

// AI 응답 메시지 저장
promptMessageService.create(
session.getId(),
aiTurnId,
aiResponse.role() != null ? aiResponse.role() : "AI", // AI 서버가 반환한 role
aiResponse.content() != null ? aiResponse.content() : "",
tokenCount != null ? tokenCount : 0, // 현재 턴 토큰 수 저장
null // 메타데이터 저장 필요시 변환
);

// 세션 토큰 사용량 업데이트
// tokenCount는 현재 턴에서 사용한 토큰 수이므로 이를 세션에 누적
if (tokenCount != null && tokenCount > 0) {
promptSessionService.addTokens(session.getId(), tokenCount);
}

// ExamParticipant의 tokenUsed도 업데이트 (토큰 한도 체크용)
// tokenCount는 현재 턴에서 사용한 토큰 수이므로 이를 추가
// 주의: totalCount는 전체 누적 값이므로 사용하지 않음
if (tokenCount != null && tokenCount > 0) {
examParticipantService.addTokenUsed(
request.examId(),
request.participantId(),
tokenCount);
}

// 참고: FastAPI에서 반환하는 totalToken은 전체 누적 토큰 수이며,
// FE에서 토큰 표시용으로 사용됩니다. 서버에서는 tokenCount만 누적합니다.

// AI 응답 반환
return aiResponse;

} catch (Exception e) {
// AI 호출 실패 시 로그 남기고 예외 전파
// 예외는 상위로 전파되어 500 에러로 반환됨
throw new RuntimeException(
String.format("AI 서버 호출 실패: %s", e.getMessage()), e);
}
// 4. AI 호출 — 트랜잭션 밖 (DB 커넥션을 잡지 않음)
Map<String, Object> context = new HashMap<>();
context.put("problemId", examParticipant.getAssignedProblemId());
context.put("specVersion", examParticipant.getSpecId());

AISendMessageRequest aiRequest = AISendMessageRequest.builder()
.sessionId(session.getId())
.participantId(request.participantId())
.turnId(nextTurn)
.role("USER")
.content(request.content())
.context(context)
.build();

SendMessageResponse aiResponse;
try {
aiResponse = aiChatService.sendMessage(aiRequest);
} catch (Exception e) {
throw new RuntimeException(String.format("AI 서버 호출 실패: %s", e.getMessage()), e);
}
if (aiResponse == null) {
throw new RuntimeException("AI 서버 응답이 null입니다.");
}

// 사용자 메시지가 아닌 경우 (예: assistant 메시지를 직접 저장하는 경우)
// FE에서는 USER 메시지만 보내므로 이 경우는 발생하지 않지만, 안전을 위해 처리
// null 반환 시 BaseResponse.onSuccess(null)이 호출되어 result가 null이 됨
// FE에서는 이를 처리할 수 있지만, 명시적으로 에러를 반환하는 것이 좋음
throw new RestApiException(GlobalErrorStatus._BAD_REQUEST);
// 5. 사후 저장 — 단일 트랜잭션 (원자성 보장)
chatResponsePersister.persistAiResponse(
session.getId(), request.examId(), request.participantId(), aiResponse);

return aiResponse;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,16 @@ public Integer getNextTurn(Long sessionId) {
Optional<Integer> maxTurn = promptMessageRepository.findMaxTurnBySessionId(sessionId);
return maxTurn.map(turn -> turn + 1).orElse(1);
}

/**
* turn 계산과 메시지 저장을 단일 트랜잭션으로 처리.
* getNextTurn + create를 별도 트랜잭션으로 분리하면 동시 요청이 같은 turn 값을
* 읽어 (session_id, turn) 유니크 제약 위반이 발생할 수 있으므로 반드시 이 메서드를 사용한다.
*/
@Transactional
public PromptMessage createWithNextTurn(Long sessionId, String role, String content,
Integer tokenCount, String meta) {
Integer nextTurn = getNextTurn(sessionId);
return create(sessionId, nextTurn, role, content, tokenCount, meta);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.yd.vibecode.domain.chat.ui;

import java.util.Map;

import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest;
import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse;
import com.yd.vibecode.domain.chat.application.usecase.SaveChatMessageUseCase;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* 채팅 메시지 비동기 처리기.
* STOMP 인바운드 스레드를 즉시 반환시키기 위해 AI 처리를 별도 스레드 풀에서 수행하고,
* 결과(또는 에러)를 사용자 큐로 push 한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChatMessageAsyncDispatcher {

private final SaveChatMessageUseCase saveChatMessageUseCase;
private final SimpMessagingTemplate messagingTemplate;

@Async("chatAsyncExecutor")
public void dispatch(SaveChatMessageRequest request) {
if (request == null || request.participantId() == null) {
log.warn("[WS Chat] Invalid chat request: participantId is null");
return;
}
String userId = request.participantId().toString();
try {
SendMessageResponse response = saveChatMessageUseCase.execute(request);
messagingTemplate.convertAndSendToUser(userId, "/queue/chat", response);
log.info("[WS Chat] AI response sent: userId={}, turnId={}", userId, response.turnId());
} catch (Exception e) {
log.error("[WS Chat] Failed to process message: userId={}, error={}", userId, e.getMessage(), e);
messagingTemplate.convertAndSendToUser(
userId, "/queue/chat-error",
Map.of("error", true, "message", "AI 응답 처리 실패"));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,68 +1,44 @@
package com.yd.vibecode.domain.chat.ui;

import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest;
import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse;
import com.yd.vibecode.domain.chat.application.usecase.SaveChatMessageUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.stereotype.Controller;

import java.util.Map;
import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Controller
@RequiredArgsConstructor
public class ChatWebSocketController {

private final SaveChatMessageUseCase saveChatMessageUseCase;
private final ChatMessageAsyncDispatcher chatMessageAsyncDispatcher;
private final SimpMessagingTemplate messagingTemplate;

/**
* 채팅 메시지 전송 (WebSocket)
* 클라이언트 전송 경로: /app/chat.send
* 클라이언트 구독 경로: /user/queue/chat
* 클라이언트 구독 경로: /user/queue/chat (응답), /user/queue/chat-error (에러)
*
* STOMP 인바운드 스레드를 블로킹하지 않도록 즉시 비동기 디스패치한다.
*/
@MessageMapping("/chat.send")
public void sendMessage(@Payload SaveChatMessageRequest request) {
log.info("[WS Chat] Message received: userId={}, content={}",
request.participantId(), request.content());

log.info("[WS Chat] Message received: userId={}", request.participantId());
try {
// 메시지 저장 및 AI 응답 생성 (기존 REST UseCase 재사용)
SendMessageResponse response = saveChatMessageUseCase.execute(request);

// 응답을 보낸 사용자에게만 전송 (/user/queue/chat)
// Spring STOMP의 convertAndSendToUser는 사용자 식별자가 필요함.
// 여기서는 participantId를 String으로 변환하여 사용하거나,
// SecurityContext가 연동되어 있으면 Principal을 사용함.
// 일단 participantId를 명시적으로 사용하여 특정 큐로 보냄.

String destination = "/queue/chat";
messagingTemplate.convertAndSendToUser(
request.participantId().toString(),
destination,
response
);

log.info("[WS Chat] AI response sent: userId={}, turnId={}",
request.participantId(), response.turnId());

} catch (Exception e) {
log.error("[WS Chat] Failed to process message: {}", e.getMessage(), e);
// 에러 응답을 FE에 전송하여 무한 로딩 방지
chatMessageAsyncDispatcher.dispatch(request);
} catch (TaskRejectedException e) {
log.error("[WS Chat] Executor queue full, rejecting request: userId={}", request.participantId());
if (request.participantId() != null) {
try {
messagingTemplate.convertAndSendToUser(
request.participantId().toString(),
"/queue/chat-error",
Map.of("error", true, "message", e.getMessage() != null ? e.getMessage() : "AI 응답 처리 실패")
);
} catch (Exception sendError) {
log.error("[WS Chat] 에러 응답 전송 실패: {}", sendError.getMessage());
}
messagingTemplate.convertAndSendToUser(
request.participantId().toString(), "/queue/chat-error",
Map.of("error", true, "message", "서버가 혼잡합니다. 잠시 후 다시 시도해주세요."));
}
}
}
Expand Down
Loading
Loading