From 10f47281df2b7a911e6e5307d8b3f14a2a8c5d8c Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 29 May 2026 15:29:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(session):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20API=20+=20FIRST=20=EC=BD=9C=EB=B0=B1=20+?= =?UTF-8?q?=20SSE=20=EC=B2=AB=20=EC=A7=88=EB=AC=B8=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/SessionCallbackService.java | 110 +++++++++++++++ .../application/SessionQueryService.java | 15 +++ .../session/application/SessionService.java | 103 +++++++++++++++ .../application/dto/MessageResult.java | 27 ++++ .../application/dto/MessageSubmitCommand.java | 8 ++ .../application/dto/SessionCreateCommand.java | 19 +++ .../application/dto/SessionResult.java | 36 +++++ .../event/SessionCreatedEvent.java | 9 ++ .../exception/SessionForbiddenException.java | 10 ++ .../SessionInvalidStateException.java | 10 ++ .../exception/SessionNotFoundException.java | 10 ++ .../session/domain/SessionContext.java | 7 + .../SessionCallbackHandler.java | 29 ++++ .../presentation/SessionController.java | 36 +++++ .../dto/SessionCreateRequest.java | 30 +++++ .../presentation/dto/SessionResponse.java | 33 +++++ .../SessionCallbackServiceFirstTest.java | 125 ++++++++++++++++++ .../application/SessionServiceCreateTest.java | 124 +++++++++++++++++ .../SessionServicePublishTest.java | 61 +++++++++ .../SessionControllerCreateTest.java | 92 +++++++++++++ 20 files changed, 894 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/MessageSubmitCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/exception/SessionForbiddenException.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/exception/SessionInvalidStateException.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/exception/SessionNotFoundException.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/infrastructure/SessionCallbackHandler.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionServiceCreateTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionServicePublishTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerCreateTest.java diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java new file mode 100644 index 00000000..71b30999 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java @@ -0,0 +1,110 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SessionCallbackService { + + private static final Logger log = LoggerFactory.getLogger(SessionCallbackService.class); + private static final String CONSUMER_NAME = "core.callback.questions"; + + private final InterviewSessionRepository sessionRepo; + private final InterviewMessageRepository messageRepo; + private final ProcessedMessageRepository processedRepo; + private final SseEventPublisher sse; + + @Transactional + public void apply(QuestionsCallbackEnvelope envelope) { + if (envelope == null || envelope.payload() == null) { + log.warn("callback.questions envelope or payload is null — skip"); + return; + } + if (isProcessed(envelope.messageId())) { + log.info("callback.questions duplicate, skip. messageId={}", envelope.messageId()); + return; + } + QuestionsCallbackPayload p = envelope.payload(); + log.info("callback.questions received. messageId={}, sessionId={}, kind={}, traceId={}", + envelope.messageId(), p.sessionId(), p.kind(), envelope.traceId()); + + if (p.isFirst()) { + applyFirstQuestion(p); + } else if (p.isFollowup()) { + applyFollowup(p); // Task G1 에서 구현 + } else if (p.isEnd()) { + applyEnd(p); // Task G1 에서 구현 + } else { + log.warn("unknown kind={}, skip. messageId={}", p.kind(), envelope.messageId()); + } + markProcessed(envelope.messageId()); + } + + private void applyFirstQuestion(QuestionsCallbackPayload p) { + InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(p.sessionId()).orElse(null); + if (session == null) { + log.warn("session not found. id={}", p.sessionId()); + return; + } + if (p.question() == null || p.question().isBlank()) { + log.warn("first question is blank. sessionId={}", p.sessionId()); + sse.publishToSession(session.getId(), SseEventType.ERROR, + Map.of("code", "FIRST_QUESTION_EMPTY", + "message", "첫 질문 생성에 실패했습니다.")); + return; + } + + session.markInProgress(); + + int nextSeq = messageRepo.findMaxSequenceBySessionId(session.getId()) + 1; + InterviewMessage msg = messageRepo.save( + InterviewMessage.interviewer(session, nextSeq, p.question(), null)); + session.incrementQuestionCount(); + + sse.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, + MessageResult.from(msg)); + sse.publishToSession(session.getId(), SseEventType.SESSION_STATE, + Map.of("sessionId", session.getId(), "state", session.getStatus().name(), + "totalQuestionCount", session.getTotalQuestionCount())); + } + + private void applyFollowup(QuestionsCallbackPayload p) { + // Task G1 에서 구현 + throw new UnsupportedOperationException("followup is implemented in Task G1"); + } + + private void applyEnd(QuestionsCallbackPayload p) { + // Task G1 에서 구현 + throw new UnsupportedOperationException("end is implemented in Task G1"); + } + + private boolean isProcessed(String messageId) { + return messageId != null && !messageId.isBlank() && processedRepo.existsById(messageId); + } + + private void markProcessed(String messageId) { + if (messageId == null || messageId.isBlank()) return; + try { + processedRepo.save(ProcessedMessage.of(messageId, CONSUMER_NAME)); + } catch (DataIntegrityViolationException ignored) { + // race: 무시 (AnalysisCallbackService 와 동일 패턴) + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java new file mode 100644 index 00000000..74ac3a0b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java @@ -0,0 +1,15 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SessionQueryService { + private final InterviewSessionRepository sessionRepo; + private final InterviewMessageRepository messageRepo; +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java new file mode 100644 index 00000000..8c501d8b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -0,0 +1,103 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; +import com.stackup.stackup.session.application.dto.SessionCreateCommand; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import com.stackup.stackup.session.application.exception.SessionForbiddenException; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionContext; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SessionService { + + private final InterviewSessionRepository sessionRepo; + private final SessionContextRepository contextRepo; + private final InterviewMessageRepository messageRepo; + private final AnalyzedDocumentRepository documentRepo; + private final UserRepository userRepo; + private final ProcessedMessageRepository processedRepo; + private final SseEventPublisher sse; + private final RabbitMessagePublisher publisher; + private final RabbitMqProperties properties; + private final ApplicationEventPublisher events; + + @Transactional + public SessionResult create(SessionCreateCommand cmd) { + User user = userRepo.findById(cmd.userId()) + .orElseThrow(() -> new IllegalArgumentException("user not found: " + cmd.userId())); + + List docs = (cmd.contextDocumentIds() == null || cmd.contextDocumentIds().isEmpty()) + ? List.of() + : documentRepo.findAllById(cmd.contextDocumentIds()); + + for (AnalyzedDocument doc : docs) { + Long ownerId = doc.getResume() != null + ? doc.getResume().getUser().getId() + : doc.getRepository().getUser().getId(); + if (!ownerId.equals(user.getId())) { + throw new SessionForbiddenException(doc.getId()); + } + } + + InterviewSession session = sessionRepo.save(InterviewSession.create( + user, cmd.title(), cmd.memo(), + cmd.mode(), cmd.interviewType(), cmd.jobCategory(), + cmd.maxQuestions(), cmd.maxDurationMinutes() + )); + + if (!docs.isEmpty()) { + List contexts = docs.stream() + .map(d -> SessionContext.of(session, d)) + .toList(); + contextRepo.saveAll(contexts); + } + + events.publishEvent(new SessionCreatedEvent( + session.getId(), + user.getId(), + new GenerateQuestionsPayload( + session.getId(), + cmd.interviewType().name(), + cmd.jobCategory().name(), + cmd.contextDocumentIds() == null ? List.of() : cmd.contextDocumentIds(), + session.getMaxQuestions() + ) + )); + + return SessionResult.from(session); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onSessionCreated(SessionCreatedEvent event) { + publisher.publishToAi( + properties.routingKeys().generateQuestions(), + event.payload(), + new MessageContext(event.userId(), event.sessionId(), null, null) + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java new file mode 100644 index 00000000..8b23308b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java @@ -0,0 +1,27 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; + +import java.time.Instant; + +public record MessageResult( + Long id, + Long sessionId, + Integer sequenceNumber, + MessageRole role, + String content, + Long parentMessageId, + MessageStatus status, + Instant createdAt +) { + public static MessageResult from(InterviewMessage m) { + return new MessageResult( + m.getId(), m.getSession().getId(), m.getSequenceNumber(), + m.getRole(), m.getContent(), + m.getParentMessage() == null ? null : m.getParentMessage().getId(), + m.getStatus(), m.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageSubmitCommand.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageSubmitCommand.java new file mode 100644 index 00000000..3d25c6cb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageSubmitCommand.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.session.application.dto; + +public record MessageSubmitCommand( + Long sessionId, + Long userId, + String content, + String idempotencyKey +) {} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java new file mode 100644 index 00000000..188ff8e6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java @@ -0,0 +1,19 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; + +import java.util.List; + +public record SessionCreateCommand( + Long userId, + String title, + String memo, + SessionMode mode, + InterviewType interviewType, + JobCategory jobCategory, + Integer maxQuestions, + Integer maxDurationMinutes, + List contextDocumentIds +) {} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java new file mode 100644 index 00000000..3a7994bb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java @@ -0,0 +1,36 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; + +import java.time.Instant; + +public record SessionResult( + Long id, + Long userId, + String title, + String memo, + SessionMode mode, + InterviewType interviewType, + JobCategory jobCategory, + Integer maxQuestions, + Integer maxDurationMinutes, + SessionStatus status, + Integer totalQuestionCount, + Instant startedAt, + Instant endedAt, + Instant createdAt +) { + public static SessionResult from(InterviewSession s) { + return new SessionResult( + s.getId(), s.getUser().getId(), s.getTitle(), s.getMemo(), + s.getMode(), s.getInterviewType(), s.getJobCategory(), + s.getMaxQuestions(), s.getMaxDurationMinutes(), + s.getStatus(), s.getTotalQuestionCount(), + s.getStartedAt(), s.getEndedAt(), s.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java new file mode 100644 index 00000000..2ba23675 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.application.event; + +import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; + +public record SessionCreatedEvent( + Long sessionId, + Long userId, + GenerateQuestionsPayload payload +) {} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionForbiddenException.java b/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionForbiddenException.java new file mode 100644 index 00000000..51498de6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionForbiddenException.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.session.application.exception; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; + +public class SessionForbiddenException extends DomainException { + public SessionForbiddenException(Long id) { + super(ApiErrorCode.SESSION_FORBIDDEN, "세션 접근 권한이 없습니다. id=" + id); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionInvalidStateException.java b/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionInvalidStateException.java new file mode 100644 index 00000000..da227b91 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionInvalidStateException.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.session.application.exception; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; + +public class SessionInvalidStateException extends DomainException { + public SessionInvalidStateException(Long id, String reason) { + super(ApiErrorCode.SESSION_INVALID_STATE, "세션 상태 오류: " + reason + ". id=" + id); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionNotFoundException.java b/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionNotFoundException.java new file mode 100644 index 00000000..7b523dc7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/exception/SessionNotFoundException.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.session.application.exception; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; + +public class SessionNotFoundException extends DomainException { + public SessionNotFoundException(Long id) { + super(ApiErrorCode.SESSION_NOT_FOUND, "세션을 찾을 수 없습니다. id=" + id); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java index 09e759b9..79a58656 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionContext.java @@ -41,4 +41,11 @@ public class SessionContext extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "document_id", nullable = false) private AnalyzedDocument document; + + public static SessionContext of(InterviewSession session, AnalyzedDocument doc) { + SessionContext c = new SessionContext(); + c.session = session; + c.document = doc; + return c; + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/infrastructure/SessionCallbackHandler.java b/backend/src/main/java/com/stackup/stackup/session/infrastructure/SessionCallbackHandler.java new file mode 100644 index 00000000..91f5d1cd --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/infrastructure/SessionCallbackHandler.java @@ -0,0 +1,29 @@ +package com.stackup.stackup.session.infrastructure; + +import com.stackup.stackup.session.application.SessionCallbackService; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +// 도메인 갱신은 application 서비스가 처리 +@Component +@RequiredArgsConstructor +public class SessionCallbackHandler { + + private static final Logger log = LoggerFactory.getLogger(SessionCallbackHandler.class); + private final SessionCallbackService callbackService; + + @RabbitListener(queues = "${app.messaging.rabbitmq.queues.names.core-callback-questions}") + public void handle(QuestionsCallbackEnvelope envelope) { + try { + callbackService.apply(envelope); + } catch (RuntimeException e) { + log.error("callback.questions processing failed. messageId={}", + envelope == null ? null : envelope.messageId(), e); + throw e; // 재시도 후 DLQ + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java new file mode 100644 index 00000000..6668cd14 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java @@ -0,0 +1,36 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.SessionQueryService; +import com.stackup.stackup.session.application.SessionService; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.presentation.dto.SessionCreateRequest; +import com.stackup.stackup.session.presentation.dto.SessionResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/sessions") +@RequiredArgsConstructor +public class SessionController { + + private final SessionService sessionService; + private final SessionQueryService queryService; // 후속 Task 에서 구현 + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public SessionResponse create( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody SessionCreateRequest request + ) { + SessionResult result = sessionService.create(request.toCommand(principal.userId())); + return SessionResponse.from(result); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java new file mode 100644 index 00000000..ba9a172b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -0,0 +1,30 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.SessionCreateCommand; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record SessionCreateRequest( + String title, + String memo, + @NotNull SessionMode mode, + @NotNull InterviewType interviewType, + @NotNull JobCategory jobCategory, + @Min(1) @Max(30) Integer maxQuestions, + @Min(5) @Max(180) Integer maxDurationMinutes, + List contextDocumentIds +) { + public SessionCreateCommand toCommand(Long userId) { + return new SessionCreateCommand( + userId, title, memo, mode, interviewType, jobCategory, + maxQuestions, maxDurationMinutes, + contextDocumentIds == null ? List.of() : contextDocumentIds + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java new file mode 100644 index 00000000..cbae94b4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java @@ -0,0 +1,33 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; + +import java.time.Instant; + +public record SessionResponse( + Long id, + String title, + String memo, + SessionMode mode, + InterviewType interviewType, + JobCategory jobCategory, + Integer maxQuestions, + Integer maxDurationMinutes, + SessionStatus status, + Integer totalQuestionCount, + Instant startedAt, + Instant endedAt, + Instant createdAt +) { + public static SessionResponse from(SessionResult r) { + return new SessionResponse( + r.id(), r.title(), r.memo(), r.mode(), r.interviewType(), r.jobCategory(), + r.maxQuestions(), r.maxDurationMinutes(), r.status(), r.totalQuestionCount(), + r.startedAt(), r.endedAt(), r.createdAt() + ); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java new file mode 100644 index 00000000..c6fe6cad --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java @@ -0,0 +1,125 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SessionCallbackServiceFirstTest { + + @Mock InterviewSessionRepository sessionRepo; + @Mock InterviewMessageRepository messageRepo; + @Mock ProcessedMessageRepository processedRepo; + @Mock SseEventPublisher sse; + + SessionCallbackService service; + + @BeforeEach + void setUp() { + service = new SessionCallbackService(sessionRepo, messageRepo, processedRepo, sse); + } + + @Test + void first_marks_in_progress_inserts_first_message_publishes_sse() { + InterviewSession s = ready(99L); + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "FIRST", "PROJECT_DEEP_DIVE", "Q1", null, null, null); + QuestionsCallbackEnvelope env = envelope("mid-1", p); + + when(processedRepo.existsById("mid-1")).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(messageRepo.findMaxSequenceBySessionId(99L)).thenReturn(0); + when(messageRepo.save(any())).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 501L); + return m; + }); + + service.apply(env); + + assertThat(s.getStatus()).isEqualTo(SessionStatus.IN_PROGRESS); + assertThat(s.getStartedAt()).isNotNull(); + assertThat(s.getTotalQuestionCount()).isEqualTo(1); + + ArgumentCaptor mc = ArgumentCaptor.forClass(InterviewMessage.class); + verify(messageRepo).save(mc.capture()); + assertThat(mc.getValue().getSequenceNumber()).isEqualTo(1); + assertThat(mc.getValue().getContent()).isEqualTo("Q1"); + assertThat(mc.getValue().getRole()).isEqualTo(MessageRole.INTERVIEWER); + + verify(sse).publishToSession(eq(99L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(processedRepo).save(any(ProcessedMessage.class)); + } + + @Test + void duplicate_messageId_is_skipped() { + when(processedRepo.existsById("mid-1")).thenReturn(true); + service.apply(envelope("mid-1", firstPayload())); + verifyNoInteractions(sessionRepo); + } + + @Test + void first_with_blank_question_publishes_error_keeps_ready() { + InterviewSession s = ready(99L); + when(processedRepo.existsById(any())).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "FIRST", null, null, null, null, null); + service.apply(envelope("mid-empty", p)); + + verify(sse).publishToSession(eq(99L), eq(SseEventType.ERROR), any()); + verifyNoInteractions(messageRepo); + assertThat(s.getStatus()).isEqualTo(SessionStatus.READY); + } + + private InterviewSession ready(Long id) { + User user = User.createGithubUser(1L, "u", "u@e.com", "url", "tok"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "t", null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, 10, 60); + ReflectionTestUtils.setField(s, "id", id); + return s; + } + + private QuestionsCallbackPayload firstPayload() { + return new QuestionsCallbackPayload(99L, "FIRST", "C", "Q", null, null, null); + } + + private QuestionsCallbackEnvelope envelope(String mid, QuestionsCallbackPayload p) { + return new QuestionsCallbackEnvelope(mid, "callback.questions", "v1", + "trace-1", Instant.now(), "ai-server", p, + new MessageContext(1L, 99L, null, null)); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceCreateTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceCreateTest.java new file mode 100644 index 00000000..65180c52 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceCreateTest.java @@ -0,0 +1,124 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.session.application.dto.SessionCreateCommand; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import com.stackup.stackup.session.application.exception.SessionForbiddenException; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionContext; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SessionServiceCreateTest { + + @Mock InterviewSessionRepository sessionRepo; + @Mock SessionContextRepository contextRepo; + @Mock InterviewMessageRepository messageRepo; + @Mock AnalyzedDocumentRepository documentRepo; + @Mock UserRepository userRepo; + @Mock ProcessedMessageRepository processedRepo; + @Mock SseEventPublisher sse; + @Mock RabbitMessagePublisher publisher; + @Mock RabbitMqProperties properties; + @Mock ApplicationEventPublisher events; + + @InjectMocks SessionService service; + + @Test + void create_inserts_session_contexts_and_publishes_event() { + User user = userStub(1L); + AnalyzedDocument doc1 = docStub(11L, user); + AnalyzedDocument doc2 = docStub(12L, user); + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + when(documentRepo.findAllById(List.of(11L, 12L))).thenReturn(List.of(doc1, doc2)); + when(sessionRepo.save(any())).thenAnswer(inv -> { + InterviewSession s = inv.getArgument(0); + ReflectionTestUtils.setField(s, "id", 99L); + return s; + }); + + SessionResult r = service.create(new SessionCreateCommand( + 1L, "Title", null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, 10, 60, List.of(11L, 12L))); + + assertThat(r.id()).isEqualTo(99L); + assertThat(r.status()).isEqualTo(SessionStatus.READY); + verify(sessionRepo).save(any(InterviewSession.class)); + verify(contextRepo).saveAll(argThat((List l) -> l.size() == 2)); + verify(events).publishEvent(any(SessionCreatedEvent.class)); + } + + @Test + void create_throws_when_user_missing() { + when(userRepo.findById(1L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.create(stubCmd(1L))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void create_throws_when_document_not_owned_by_user() { + User user = userStub(1L); + AnalyzedDocument doc = docStub(11L, userStub(2L)); // 다른 user + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + when(documentRepo.findAllById(List.of(11L))).thenReturn(List.of(doc)); + assertThatThrownBy(() -> service.create(stubCmd(1L, 11L))) + .isInstanceOf(SessionForbiddenException.class); + } + + private User userStub(Long id) { + User user = User.createGithubUser( + 123L, "stub-user", "stub@example.com", null, "encrypted-token" + ); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private AnalyzedDocument docStub(Long docId, User owner) { + Resume resume = Resume.create(owner, "resume.pdf", "/path/to/resume.pdf", ResumeFileType.PDF, 1024L); + ReflectionTestUtils.setField(resume, "id", docId * 100); // arbitrary resume id + AnalyzedDocument doc = AnalyzedDocument.forResume(resume); + ReflectionTestUtils.setField(doc, "id", docId); + return doc; + } + + private SessionCreateCommand stubCmd(Long userId, Long... docIds) { + List ids = docIds.length == 0 ? List.of() : List.of(docIds); + return new SessionCreateCommand( + userId, "Title", null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, 10, 60, ids + ); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServicePublishTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServicePublishTest.java new file mode 100644 index 00000000..e79e1b1a --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServicePublishTest.java @@ -0,0 +1,61 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; +import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SessionServicePublishTest { + + @Mock InterviewSessionRepository sessionRepo; + @Mock SessionContextRepository contextRepo; + @Mock InterviewMessageRepository messageRepo; + @Mock AnalyzedDocumentRepository documentRepo; + @Mock UserRepository userRepo; + @Mock ProcessedMessageRepository processedRepo; + @Mock SseEventPublisher sse; + @Mock RabbitMessagePublisher publisher; + @Mock RabbitMqProperties properties; + @Mock RabbitMqProperties.RoutingKeyProperties routingKeys; + @Mock ApplicationEventPublisher events; + + @InjectMocks SessionService service; + + @Test + void onSessionCreated_publishes_generate_questions() { + when(properties.routingKeys()).thenReturn(routingKeys); + when(routingKeys.generateQuestions()).thenReturn("generate.questions"); + + GenerateQuestionsPayload payload = new GenerateQuestionsPayload( + 99L, "TECHNICAL", "BACKEND", List.of(11L, 12L), 10); + service.onSessionCreated(new SessionCreatedEvent(99L, 1L, payload)); + + ArgumentCaptor ctx = ArgumentCaptor.forClass(MessageContext.class); + verify(publisher).publishToAi(eq("generate.questions"), eq(payload), ctx.capture()); + assertThat(ctx.getValue().userId()).isEqualTo(1L); + assertThat(ctx.getValue().sessionId()).isEqualTo(99L); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerCreateTest.java b/backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerCreateTest.java new file mode 100644 index 00000000..acc05f0c --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerCreateTest.java @@ -0,0 +1,92 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.SessionQueryService; +import com.stackup.stackup.session.application.SessionService; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class SessionControllerCreateTest { + + @Mock + SessionService service; + + @Mock + SessionQueryService queryService; + + MockMvc mvc; + + private static final UserPrincipal TEST_PRINCIPAL = + new UserPrincipal(1L, "octocat", List.of()); + + @BeforeEach + void setUp() { + SessionController controller = new SessionController(service, queryService); + mvc = MockMvcBuilders + .standaloneSetup(controller) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + // @AuthenticationPrincipal 이 SecurityContext 에서 principal 을 읽으므로 미리 세팅 + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(TEST_PRINCIPAL, null, List.of()) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void post_creates_session_returns_201() throws Exception { + SessionResult r = new SessionResult( + 99L, 1L, "T", null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, 10, 60, SessionStatus.READY, 0, null, null, Instant.now()); + when(service.create(any())).thenReturn(r); + + mvc.perform(post("/api/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"T","mode":"ONLINE","interviewType":"TECHNICAL", + "jobCategory":"BACKEND","maxQuestions":10,"maxDurationMinutes":60, + "contextDocumentIds":[11,12]}""")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(99)) + .andExpect(jsonPath("$.status").value("READY")); + } + + @Test + void post_validation_error_returns_400() throws Exception { + mvc.perform(post("/api/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mode":"ONLINE","interviewType":"TECHNICAL","jobCategory":"BACKEND", + "maxQuestions":999}""")) // 30 초과 + .andExpect(status().isBadRequest()); + } +} From 8185b744f9c43a875b324a81c374c8023f29cd63 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 29 May 2026 17:32:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(session):=20=EB=8B=B5=EB=B3=80/?= =?UTF-8?q?=EA=BC=AC=EB=A6=AC=EC=A7=88=EB=AC=B8/=EC=A2=85=EB=A3=8C=20+=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/CLAUDE.md | 7 +- .../com/stackup/stackup/session/CLAUDE.md | 72 +++++++ .../application/SessionCallbackService.java | 74 ++++++- .../application/SessionQueryService.java | 36 ++++ .../session/application/SessionService.java | 103 ++++++++++ .../event/AnswerSubmittedEvent.java | 9 + .../presentation/SessionController.java | 64 ++++++ .../presentation/dto/AnswerSubmitRequest.java | 8 + .../dto/InterviewMessageResponse.java | 24 +++ .../presentation/dto/SessionEndRequest.java | 5 + .../stackup/session/SessionGoldenPathIT.java | 157 ++++++++++++++ .../SessionCallbackServiceFirstTest.java | 22 ++ .../SessionCallbackServiceFollowupTest.java | 175 ++++++++++++++++ .../application/SessionQueryServiceTest.java | 134 ++++++++++++ .../application/SessionServiceEndTest.java | 125 ++++++++++++ .../SessionServiceSubmitAnswerTest.java | 193 ++++++++++++++++++ .../SessionControllerSubmitAnswerTest.java | 84 ++++++++ backend/src/test/resources/application-it.yml | 106 ++++++++++ docs/data-flow.md | 29 ++- docs/messaging.md | 30 +-- 20 files changed, 1427 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/CLAUDE.md create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/AnswerSubmitRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/InterviewMessageResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionEndRequest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/SessionGoldenPathIT.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFollowupTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionQueryServiceTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionServiceEndTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionServiceSubmitAnswerTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerSubmitAnswerTest.java create mode 100644 backend/src/test/resources/application-it.yml diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index f89f21bb..fa750834 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -87,7 +87,7 @@ com.stackup.stackup.{domain}/ | `github` | GitHub API 연동, 레포 목록/등록/메타 동기화 | US-07, US-08 | | `resume` | 이력서 업로드(S3)·메타 저장·목록·삭제 | US-05, US-06 | | `document` | 분석 문서(이력서/레포 공통) 메타 + S3 경로 | US-09~12 | -| `session` | 면접 세션·메시지·피드백 (가장 큰 도메인) | US-13~20, US-24~27 | +| `session` | 면접 세션·메시지·콜백. Sprint 2 에서 application/presentation/infrastructure 도입 (create/submitAnswer/end + callback.questions FIRST/FOLLOWUP/END + SSE push). 피드백은 Sprint 3. | US-13~20 | | `log.activity` | 사용자 행동 로그 | US-31 | | `log.ai` | AI 요청/응답 로깅 | US-30 | | `common` | BaseEntity, 글로벌 예외 핸들러, util | — | @@ -359,5 +359,10 @@ docker compose up -d - Flyway 미도입 → 첫 entity 작성 PR에서 도입 - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. +- (2026-05) session 도메인 Phase A·B·C·D·E·F·G·H 완료 — 도메인 메서드 + 메시지 DTO + + SessionService.create/submitAnswer/end + SessionController + callback.questions + (FIRST/FOLLOWUP/END) 컨슈머 + SSE SESSION_MESSAGE/STATE push + 답변 멱등 (Idempotency-Key). + 풀 미사용, 매 턴 LLM 호출. DB 마이그레이션 없음. golden path Testcontainer IT 추가 + (Docker 필요). 각 도입 시 본 문서 §1, 관련 도메인 `CLAUDE.md` 갱신. diff --git a/backend/src/main/java/com/stackup/stackup/session/CLAUDE.md b/backend/src/main/java/com/stackup/stackup/session/CLAUDE.md new file mode 100644 index 00000000..722a7456 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/CLAUDE.md @@ -0,0 +1,72 @@ +# `session` — 면접 세션 도메인 가이드 + +> 상위 컨텍스트: [`/backend/CLAUDE.md`](../../../../../CLAUDE.md), [`/backend/src/main/java/com/stackup/stackup/CLAUDE.md`](../CLAUDE.md) + +--- + +## 1. Aggregate + +`InterviewSession` (root). 연관: +- `InterviewMessage` (질문/답변 시퀀스 — `(session_id, sequence_number) UNIQUE`) +- `SessionContext` (세션 ↔ `AnalyzedDocument` N:M) +- `MessageVoiceAnalysis` (Phase 2 — 음성) +- `SessionFeedback` (Sprint 3 — 종합 평가) + +`InterviewMessage`, `SessionContext` 등 자식 aggregate 는 Repository 가 별도이며 service 가 명시적으로 INSERT/조회. + +## 2. 상태 전이 (`SessionStatus`) + +``` + create() + ↓ +READY ──markInProgress()──> IN_PROGRESS ──end()──> COMPLETED + │ │ + └──cancel()──> CANCELLED └──cancel()──> CANCELLED + │ + └──(network 등)──> INTERRUPTED *(현재 코드 진입 없음)* +``` + +전이 가드는 `InterviewSession` 도메인 메서드 내부 `IllegalStateException`. 서비스 계층에서는 `SessionInvalidStateException`(`SESSION_INVALID_STATE` → HTTP 422) 으로 사전 차단. + +## 3. API endpoints (`/api/sessions`) + +| Method | Path | 설명 | +|--------|------|------| +| POST | `/` | 세션 생성 (READY) + `generate.questions` 발행 | +| POST | `/{id}/messages` | 답변 제출 (`Idempotency-Key` 헤더). `generate.followup` 발행 | +| POST | `/{id}/end` | 세션 종료 / 취소 | +| GET | `/{id}` | 단건 조회 | +| GET | `/` | 페이지 목록 (생성일 DESC) | +| GET | `/{id}/messages` | 메시지 시퀀스 ASC | + +## 4. 메시지 + +발행 (`session.application.SessionService`): +- `generate.questions` — 세션 생성 commit 후 AFTER_COMMIT 이벤트 (첫 질문 요청) +- `generate.followup` — 답변 commit 후 AFTER_COMMIT (꼬리질문 요청) + +소비 (`session.infrastructure.SessionCallbackHandler` → `session.application.SessionCallbackService`): +- `core.callback.questions` 큐 — `kind=FIRST/FOLLOWUP/END` 분기 + - `FIRST` → `markInProgress` + 첫 INTERVIEWER 메시지 INSERT + SSE + - `FOLLOWUP` → INTERVIEWER 메시지 INSERT + max 도달 시 자동 종료 + SSE + - `END` → AI 가 조기 종료 신호. `session.end()` + SSE + - 멱등: `processed_messages` (`messageId` PK) + +## 5. 답변 멱등 정책 + +`POST /messages` 의 `Idempotency-Key` 헤더 → `processed_messages` 의 PK `session.answer:{uuid}` 로 24h 캐시. 중복 호출 시 가장 최근 INTERVIEWEE 메시지를 그대로 반환 (멱등 충돌 시 시퀀스 중복 방지). + +## 6. SSE 이벤트 + +- `SESSION_MESSAGE` — 새 INTERVIEWER 메시지 도착 시 (FIRST/FOLLOWUP) +- `SESSION_STATE` — 상태 전이 또는 카운트 변화 시 +- `ERROR` — 콜백 페이로드 비정상 (예: 빈 question) + +전송 경로: Core 인메모리 (`SseEventPublisher.publishToSession`). RealTime 서버 미사용 (Phase 1). + +## 7. 안티패턴 + +- 컨트롤러에서 `@Transactional` (ArchUnit 차단) +- 엔티티에 `@Setter` (ArchUnit 차단) +- Service 에 클래스 레벨 `@Transactional` + `@TransactionalEventListener` 혼용 (Spring 7 제약 — `AnalysisRequestService` 패턴 참조) +- `Map.of(...)` 에 null 값 (NPE) diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java index 71b30999..598f439b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionCallbackService.java @@ -11,6 +11,7 @@ import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionStatus; import java.util.Map; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -63,6 +64,11 @@ private void applyFirstQuestion(QuestionsCallbackPayload p) { log.warn("session not found. id={}", p.sessionId()); return; } + if (session.getStatus() != SessionStatus.READY) { + log.warn("session not READY, drop FIRST. id={}, status={}", + session.getId(), session.getStatus()); + return; + } if (p.question() == null || p.question().isBlank()) { log.warn("first question is blank. sessionId={}", p.sessionId()); sse.publishToSession(session.getId(), SseEventType.ERROR, @@ -86,13 +92,73 @@ private void applyFirstQuestion(QuestionsCallbackPayload p) { } private void applyFollowup(QuestionsCallbackPayload p) { - // Task G1 에서 구현 - throw new UnsupportedOperationException("followup is implemented in Task G1"); + InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(p.sessionId()).orElse(null); + if (session == null) { + log.warn("session not found. id={}", p.sessionId()); + return; + } + if (session.getStatus() != SessionStatus.IN_PROGRESS) { + log.warn("session not in progress, drop followup. id={}, status={}", + session.getId(), session.getStatus()); + return; + } + if (p.parentMessageId() == null) { + log.warn("followup missing parentMessageId. sessionId={}", session.getId()); + return; + } + if (p.question() == null || p.question().isBlank()) { + log.warn("followup question blank. sessionId={}", session.getId()); + return; + } + + InterviewMessage parent = messageRepo.findById(p.parentMessageId()).orElse(null); + if (parent == null) { + log.warn("followup parent not found. id={}", p.parentMessageId()); + return; + } + if (!parent.getSession().getId().equals(session.getId())) { + log.warn("followup parent belongs to different session. parentId={}, parent.sessionId={}, expected.sessionId={}", + parent.getId(), parent.getSession().getId(), session.getId()); + return; + } + + int nextSeq = messageRepo.findMaxSequenceBySessionId(session.getId()) + 1; + InterviewMessage q = messageRepo.save( + InterviewMessage.interviewer(session, nextSeq, p.question(), parent)); + session.incrementQuestionCount(); + + sse.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, + MessageResult.from(q)); + + if (session.isMaxReached()) { + session.end(); + sse.publishToSession(session.getId(), SseEventType.SESSION_STATE, + Map.of("sessionId", session.getId(), "state", session.getStatus().name(), + "totalQuestionCount", session.getTotalQuestionCount(), + "endedAt", session.getEndedAt())); + } else { + sse.publishToSession(session.getId(), SseEventType.SESSION_STATE, + Map.of("sessionId", session.getId(), "state", session.getStatus().name(), + "totalQuestionCount", session.getTotalQuestionCount())); + } } private void applyEnd(QuestionsCallbackPayload p) { - // Task G1 에서 구현 - throw new UnsupportedOperationException("end is implemented in Task G1"); + InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(p.sessionId()).orElse(null); + if (session == null) { + log.warn("session not found. id={}", p.sessionId()); + return; + } + if (session.getStatus() != SessionStatus.IN_PROGRESS) { + log.warn("session not in progress, drop END. id={}, status={}", + session.getId(), session.getStatus()); + return; + } + session.end(); + sse.publishToSession(session.getId(), SseEventType.SESSION_STATE, + Map.of("sessionId", session.getId(), "state", session.getStatus().name(), + "totalQuestionCount", session.getTotalQuestionCount(), + "endedAt", session.getEndedAt())); } private boolean isProcessed(String messageId) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java index 74ac3a0b..d5af083a 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionQueryService.java @@ -1,15 +1,51 @@ package com.stackup.stackup.session.application; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.exception.SessionForbiddenException; +import com.stackup.stackup.session.application.exception.SessionNotFoundException; +import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class SessionQueryService { + private final InterviewSessionRepository sessionRepo; private final InterviewMessageRepository messageRepo; + + public SessionResult get(Long sessionId, Long userId) { + InterviewSession s = sessionRepo.findByIdAndIsDeletedFalse(sessionId) + .orElseThrow(() -> new SessionNotFoundException(sessionId)); + if (!s.getUser().getId().equals(userId)) { + throw new SessionForbiddenException(sessionId); + } + return SessionResult.from(s); + } + + public Page list(Long userId, Pageable pageable) { + return sessionRepo + .findByUser_IdAndIsDeletedFalseOrderByCreatedAtDesc(userId, pageable) + .map(SessionResult::from); + } + + public List getMessages(Long sessionId, Long userId) { + InterviewSession s = sessionRepo.findByIdAndIsDeletedFalse(sessionId) + .orElseThrow(() -> new SessionNotFoundException(sessionId)); + if (!s.getUser().getId().equals(userId)) { + throw new SessionForbiddenException(sessionId); + } + return messageRepo.findBySession_IdOrderBySequenceNumberAsc(sessionId) + .stream().map(MessageResult::from).toList(); + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 8c501d8b..8b9dff19 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -3,25 +3,36 @@ import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.messaging.MessageContext; import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.document.domain.AnalyzedDocument; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.GenerateFollowupPayload; import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.MessageSubmitCommand; import com.stackup.stackup.session.application.dto.SessionCreateCommand; import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; import com.stackup.stackup.session.application.event.SessionCreatedEvent; import com.stackup.stackup.session.application.exception.SessionForbiddenException; +import com.stackup.stackup.session.application.exception.SessionInvalidStateException; +import com.stackup.stackup.session.application.exception.SessionNotFoundException; import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionStatus; import com.stackup.stackup.user.domain.User; import com.stackup.stackup.user.domain.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -29,11 +40,14 @@ import org.springframework.transaction.event.TransactionalEventListener; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor public class SessionService { + private static final String ANSWER_IDEMPOTENCY_CONSUMER = "session.answer"; + private final InterviewSessionRepository sessionRepo; private final SessionContextRepository contextRepo; private final InterviewMessageRepository messageRepo; @@ -100,4 +114,93 @@ public void onSessionCreated(SessionCreatedEvent event) { new MessageContext(event.userId(), event.sessionId(), null, null) ); } + + @Transactional + public MessageResult submitAnswer(MessageSubmitCommand cmd) { + InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(cmd.sessionId()) + .orElseThrow(() -> new SessionNotFoundException(cmd.sessionId())); + + if (!session.getUser().getId().equals(cmd.userId())) { + throw new SessionForbiddenException(cmd.sessionId()); + } + + String idemKey = idempotencyKey(cmd.sessionId(), cmd.idempotencyKey()); + if (idemKey != null && processedRepo.existsById(idemKey)) { + InterviewMessage last = messageRepo + .findFirstBySession_IdOrderBySequenceNumberDesc(cmd.sessionId()) + .orElseThrow(() -> new IllegalStateException("멱등 캐시는 있는데 메시지가 없습니다.")); + return MessageResult.from(last); + } + + if (session.getStatus() != SessionStatus.IN_PROGRESS) { + throw new SessionInvalidStateException(cmd.sessionId(), + "IN_PROGRESS 가 아닙니다. 현재=" + session.getStatus()); + } + + InterviewMessage parentQuestion = messageRepo + .findFirstBySession_IdOrderBySequenceNumberDesc(cmd.sessionId()) + .orElseThrow(() -> new SessionInvalidStateException(cmd.sessionId(), "직전 질문이 없습니다.")); + + if (parentQuestion.getRole() != MessageRole.INTERVIEWER) { + throw new SessionInvalidStateException(cmd.sessionId(), + "직전 메시지가 질문이 아닙니다. 꼬리질문을 기다리세요. role=" + parentQuestion.getRole()); + } + + int nextSeq = messageRepo.findMaxSequenceBySessionId(cmd.sessionId()) + 1; + InterviewMessage answer = messageRepo.save( + InterviewMessage.interviewee(session, nextSeq, cmd.content(), parentQuestion)); + + if (idemKey != null) { + try { + processedRepo.save(ProcessedMessage.of(idemKey, ANSWER_IDEMPOTENCY_CONSUMER)); + } catch (DataIntegrityViolationException ignored) { + // race: 다른 worker 가 먼저 캐싱했으므로 그대로 진행 + } + } + + events.publishEvent(new AnswerSubmittedEvent( + session.getId(), session.getUser().getId(), + parentQuestion.getId(), answer.getId(), cmd.content())); + + return MessageResult.from(answer); + } + + @Transactional + public SessionResult end(Long sessionId, Long userId, boolean cancel) { + InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(sessionId) + .orElseThrow(() -> new SessionNotFoundException(sessionId)); + if (!session.getUser().getId().equals(userId)) { + throw new SessionForbiddenException(sessionId); + } + if (session.getStatus() == SessionStatus.COMPLETED || session.getStatus() == SessionStatus.CANCELLED) { + throw new SessionInvalidStateException(sessionId, "이미 종료된 세션입니다. 현재=" + session.getStatus()); + } + if (cancel) { + session.cancel(); + } else { + session.end(); + } + sse.publishToSession(sessionId, SseEventType.SESSION_STATE, + Map.of("sessionId", sessionId, "state", session.getStatus().name(), + "totalQuestionCount", session.getTotalQuestionCount(), + "endedAt", session.getEndedAt())); + return SessionResult.from(session); + } + + private String idempotencyKey(Long sessionId, String raw) { + if (raw == null || raw.isBlank()) return null; + return ANSWER_IDEMPOTENCY_CONSUMER + ":" + sessionId + ":" + raw; + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onAnswerSubmitted(AnswerSubmittedEvent event) { + publisher.publishToAi( + properties.routingKeys().generateFollowup(), + new GenerateFollowupPayload( + event.sessionId(), event.parentQuestionMessageId(), + event.answerMessageId(), event.answerText(), null), + new MessageContext(event.userId(), event.sessionId(), null, null) + ); + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java new file mode 100644 index 00000000..1db87edd --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/AnswerSubmittedEvent.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.application.event; + +public record AnswerSubmittedEvent( + Long sessionId, + Long userId, + Long parentQuestionMessageId, + Long answerMessageId, + String answerText +) {} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java index 6668cd14..749b1c9c 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java @@ -1,21 +1,36 @@ package com.stackup.stackup.session.presentation; +import com.stackup.stackup.common.response.PageResponse; import com.stackup.stackup.common.security.UserPrincipal; import com.stackup.stackup.session.application.SessionQueryService; import com.stackup.stackup.session.application.SessionService; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.MessageSubmitCommand; import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.presentation.dto.AnswerSubmitRequest; +import com.stackup.stackup.session.presentation.dto.InterviewMessageResponse; import com.stackup.stackup.session.presentation.dto.SessionCreateRequest; +import com.stackup.stackup.session.presentation.dto.SessionEndRequest; import com.stackup.stackup.session.presentation.dto.SessionResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequestMapping("/api/sessions") @RequiredArgsConstructor @@ -33,4 +48,53 @@ public SessionResponse create( SessionResult result = sessionService.create(request.toCommand(principal.userId())); return SessionResponse.from(result); } + + @PostMapping("/{sessionId}/end") + public SessionResponse end( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId, + @RequestBody(required = false) SessionEndRequest request + ) { + boolean cancel = request != null && request.isCancel(); + return SessionResponse.from(sessionService.end(sessionId, principal.userId(), cancel)); + } + + @PostMapping("/{sessionId}/messages") + @ResponseStatus(HttpStatus.CREATED) + public InterviewMessageResponse submitAnswer( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @Valid @RequestBody AnswerSubmitRequest request + ) { + MessageResult r = sessionService.submitAnswer(new MessageSubmitCommand( + sessionId, principal.userId(), request.content(), idempotencyKey)); + return InterviewMessageResponse.from(r); + } + + @GetMapping("/{sessionId}") + public SessionResponse get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(queryService.get(sessionId, principal.userId())); + } + + @GetMapping + public PageResponse list( + @AuthenticationPrincipal UserPrincipal principal, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page page = queryService.list(principal.userId(), pageable); + return PageResponse.from(page.map(SessionResponse::from)); + } + + @GetMapping("/{sessionId}/messages") + public List getMessages( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return queryService.getMessages(sessionId, principal.userId()) + .stream().map(InterviewMessageResponse::from).toList(); + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/AnswerSubmitRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/AnswerSubmitRequest.java new file mode 100644 index 00000000..699bc88c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/AnswerSubmitRequest.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.session.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AnswerSubmitRequest( + @NotBlank @Size(max = 10_000) String content +) {} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/InterviewMessageResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/InterviewMessageResponse.java new file mode 100644 index 00000000..3a7b421c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/InterviewMessageResponse.java @@ -0,0 +1,24 @@ +package com.stackup.stackup.session.presentation.dto; + +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; + +import java.time.Instant; + +public record InterviewMessageResponse( + Long id, + Long sessionId, + Integer sequenceNumber, + MessageRole role, + String content, + Long parentMessageId, + MessageStatus status, + Instant createdAt +) { + public static InterviewMessageResponse from(MessageResult r) { + return new InterviewMessageResponse( + r.id(), r.sessionId(), r.sequenceNumber(), r.role(), r.content(), + r.parentMessageId(), r.status(), r.createdAt()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionEndRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionEndRequest.java new file mode 100644 index 00000000..d6285546 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionEndRequest.java @@ -0,0 +1,5 @@ +package com.stackup.stackup.session.presentation.dto; + +public record SessionEndRequest(Boolean cancel) { + public boolean isCancel() { return Boolean.TRUE.equals(cancel); } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/SessionGoldenPathIT.java b/backend/src/test/java/com/stackup/stackup/session/SessionGoldenPathIT.java new file mode 100644 index 00000000..235eae86 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/SessionGoldenPathIT.java @@ -0,0 +1,157 @@ +package com.stackup.stackup.session; + +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.session.application.SessionCallbackService; +import com.stackup.stackup.session.application.SessionQueryService; +import com.stackup.stackup.session.application.SessionService; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.MessageSubmitCommand; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; +import com.stackup.stackup.session.application.dto.SessionCreateCommand; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.RabbitMQContainer; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("it") +class SessionGoldenPathIT { + + static final PostgreSQLContainer pg; + static final RabbitMQContainer mq; + + static { + pg = new PostgreSQLContainer<>("pgvector/pgvector:pg16"); + mq = new RabbitMQContainer("rabbitmq:3-management"); + pg.start(); + mq.start(); + } + + @DynamicPropertySource + static void props(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", pg::getJdbcUrl); + r.add("spring.datasource.username", pg::getUsername); + r.add("spring.datasource.password", pg::getPassword); + r.add("spring.rabbitmq.host", mq::getHost); + r.add("spring.rabbitmq.port", mq::getAmqpPort); + } + + @Autowired SessionService sessionService; + @Autowired SessionCallbackService callbackService; + @Autowired SessionQueryService queryService; + @Autowired InterviewMessageRepository messageRepo; + + @Autowired UserRepository userRepo; + @Autowired ResumeRepository resumeRepo; + @Autowired AnalyzedDocumentRepository documentRepo; + + @Test + void create_then_first_then_answer_then_followup_then_complete() { + // fixture: user + analyzed document + Long userId = givenUser(); + Long docId = givenAnalyzedDocumentForUser(userId); + + // 1. 세션 생성 → status=READY, max_questions=2 + SessionResult created = sessionService.create(new SessionCreateCommand( + userId, "IT 테스트 세션", null, + SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, + 2, // maxQuestions = 2 (FOLLOWUP 한 번 후 자동 완료) + 60, + List.of(docId))); + assertThat(created.status()).isEqualTo(SessionStatus.READY); + Long sessionId = created.id(); + + // 2. AI 가 FIRST callback 발행한 것을 시뮬레이트 + callbackService.apply(new QuestionsCallbackEnvelope( + "mid-first", "callback.questions", "v1", "trace-1", + Instant.now(), "ai-server", + new QuestionsCallbackPayload( + sessionId, "FIRST", "PROJECT_DEEP_DIVE", + "자신의 프로젝트에서 가장 도전적이었던 기술적 문제는 무엇이었나요?", + null, null, null), + new MessageContext(userId, sessionId, null, null))); + + // 3. 세션 IN_PROGRESS + 첫 질문 INSERT 검증 + SessionResult afterFirst = queryService.get(sessionId, userId); + assertThat(afterFirst.status()).isEqualTo(SessionStatus.IN_PROGRESS); + List ms1 = queryService.getMessages(sessionId, userId); + assertThat(ms1).hasSize(1); + assertThat(ms1.get(0).role()).isEqualTo(MessageRole.INTERVIEWER); + + // 4. 답변 제출 + MessageResult answer1 = sessionService.submitAnswer(new MessageSubmitCommand( + sessionId, userId, "저는 분산 시스템에서 데이터 일관성 문제를 해결했습니다.", "idem-1")); + assertThat(answer1.role()).isEqualTo(MessageRole.INTERVIEWEE); + + // 5. 멱등 — 같은 키로 재전송 시 같은 메시지 반환 + MessageResult answer1Dup = sessionService.submitAnswer(new MessageSubmitCommand( + sessionId, userId, "내 답변 1 (재전송)", "idem-1")); + assertThat(answer1Dup.id()).isEqualTo(answer1.id()); + + // 6. AI 가 FOLLOWUP callback 발행 → max=2 도달 → 자동 COMPLETED + callbackService.apply(new QuestionsCallbackEnvelope( + "mid-fu", "callback.questions", "v1", "trace-2", + Instant.now(), "ai-server", + new QuestionsCallbackPayload( + sessionId, "FOLLOWUP", "CS_FUNDAMENTAL", + "그 문제를 해결하기 위해 어떤 알고리즘을 사용했나요?", + answer1.id(), null, null), + new MessageContext(userId, sessionId, null, null))); + + // 7. 세션 COMPLETED + endedAt 설정 검증 + SessionResult complete = queryService.get(sessionId, userId); + assertThat(complete.status()).isEqualTo(SessionStatus.COMPLETED); + assertThat(complete.endedAt()).isNotNull(); + + // 8. 메시지 3개: Q1(first), A1(answer), Q2(followup) + List ms2 = queryService.getMessages(sessionId, userId); + assertThat(ms2).hasSize(3); + assertThat(ms2.get(0).role()).isEqualTo(MessageRole.INTERVIEWER); // Q1 + assertThat(ms2.get(1).role()).isEqualTo(MessageRole.INTERVIEWEE); // A1 + assertThat(ms2.get(2).role()).isEqualTo(MessageRole.INTERVIEWER); // Q2 (followup) + } + + private Long givenUser() { + User user = User.createGithubUser( + System.nanoTime(), // unique githubId per test run + "it-user", + "it-user@example.com", + null, + "encrypted-token-stub" + ); + return userRepo.save(user).getId(); + } + + private Long givenAnalyzedDocumentForUser(Long userId) { + User user = userRepo.findById(userId).orElseThrow(); + Resume resume = resumeRepo.save( + Resume.create(user, "cv.pdf", "resumes/raw/cv.pdf", ResumeFileType.PDF, 1024L)); + AnalyzedDocument doc = documentRepo.save(AnalyzedDocument.forResume(resume)); + return doc.getId(); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java index c6fe6cad..c91577dc 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFirstTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -103,6 +104,21 @@ void first_with_blank_question_publishes_error_keeps_ready() { assertThat(s.getStatus()).isEqualTo(SessionStatus.READY); } + @Test + void first_dropped_when_session_already_in_progress() { + InterviewSession s = inProgressSession(99L); // already IN_PROGRESS + when(processedRepo.existsById("mid-redeliver")).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "FIRST", "C", "Q", null, null, null); + service.apply(envelope("mid-redeliver", p)); + + verify(messageRepo, never()).save(any(InterviewMessage.class)); + verify(processedRepo).save(any()); // 멱등 기록은 됨 — 재배달 시 skip + assertThat(s.getStatus()).isEqualTo(SessionStatus.IN_PROGRESS); // unchanged + } + private InterviewSession ready(Long id) { User user = User.createGithubUser(1L, "u", "u@e.com", "url", "tok"); ReflectionTestUtils.setField(user, "id", 1L); @@ -113,6 +129,12 @@ private InterviewSession ready(Long id) { return s; } + private InterviewSession inProgressSession(Long id) { + InterviewSession s = ready(id); + s.markInProgress(); + return s; + } + private QuestionsCallbackPayload firstPayload() { return new QuestionsCallbackPayload(99L, "FIRST", "C", "Q", null, null, null); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFollowupTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFollowupTest.java new file mode 100644 index 00000000..47123172 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionCallbackServiceFollowupTest.java @@ -0,0 +1,175 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SessionCallbackServiceFollowupTest { + + @Mock InterviewSessionRepository sessionRepo; + @Mock InterviewMessageRepository messageRepo; + @Mock ProcessedMessageRepository processedRepo; + @Mock SseEventPublisher sse; + SessionCallbackService service; + + @BeforeEach + void setUp() { + service = new SessionCallbackService(sessionRepo, messageRepo, processedRepo, sse); + } + + @Test + void followup_inserts_interviewer_message_links_parent_pushes_sse() { + InterviewSession s = inProgressWith(99L, 1, 10); // count=1, max=10 + InterviewMessage parentAnswer = intervieweeMessage(s, 2, 502L); + when(processedRepo.existsById("mid-2")).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(messageRepo.findById(502L)).thenReturn(Optional.of(parentAnswer)); + when(messageRepo.findMaxSequenceBySessionId(99L)).thenReturn(2); + when(messageRepo.save(any())).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 503L); + return m; + }); + + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "FOLLOWUP", "CS_FUNDAMENTAL", "왜?", 502L, null, null); + service.apply(envelope("mid-2", p)); + + ArgumentCaptor mc = ArgumentCaptor.forClass(InterviewMessage.class); + verify(messageRepo).save(mc.capture()); + assertThat(mc.getValue().getRole()).isEqualTo(MessageRole.INTERVIEWER); + assertThat(mc.getValue().getSequenceNumber()).isEqualTo(3); + assertThat(mc.getValue().getParentMessage()).isSameAs(parentAnswer); + assertThat(s.getTotalQuestionCount()).isEqualTo(2); + verify(sse).publishToSession(eq(99L), eq(SseEventType.SESSION_MESSAGE), any()); + } + + @Test + void followup_with_unknown_parent_message_logs_and_drops() { + when(processedRepo.existsById(any())).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)) + .thenReturn(Optional.of(inProgressWith(99L, 1, 10))); + when(messageRepo.findById(999L)).thenReturn(Optional.empty()); + + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "FOLLOWUP", "C", "?", 999L, null, null); + service.apply(envelope("mid-3", p)); + + verify(messageRepo, never()).save(any(InterviewMessage.class)); + verify(processedRepo).save(any()); // 처리 완료로 기록 (재시도 무의미) + } + + @Test + void followup_auto_completes_session_when_max_reached() { + InterviewSession s = inProgressWith(99L, 9, 10); // 다음 INSERT 후 10 도달 + InterviewMessage parent = intervieweeMessage(s, 18, 519L); + when(processedRepo.existsById(any())).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(messageRepo.findById(519L)).thenReturn(Optional.of(parent)); + when(messageRepo.findMaxSequenceBySessionId(99L)).thenReturn(18); + when(messageRepo.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "FOLLOWUP", "C", "마지막 질문?", 519L, null, null); + service.apply(envelope("mid-final", p)); + + assertThat(s.getStatus()).isEqualTo(SessionStatus.COMPLETED); + assertThat(s.getEndedAt()).isNotNull(); + verify(sse).publishToSession(eq(99L), eq(SseEventType.SESSION_STATE), + argThat(payload -> payload.toString().contains("COMPLETED"))); + } + + @Test + void end_kind_completes_session_without_insert() { + InterviewSession s = inProgressWith(99L, 5, 10); // max 미도달이지만 AI 가 조기 종료 신호 + when(processedRepo.existsById(any())).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "END", null, null, null, Map.of("specificity", 4.1), null); + service.apply(envelope("mid-end", p)); + + assertThat(s.getStatus()).isEqualTo(SessionStatus.COMPLETED); + verify(messageRepo, never()).save(any(InterviewMessage.class)); + } + + @Test + void followup_dropped_when_parent_belongs_to_different_session() { + InterviewSession s99 = inProgressWith(99L, 1, 10); + InterviewSession sOther = inProgressWith(77L, 1, 10); + InterviewMessage parentInOtherSession = intervieweeMessage(sOther, 2, 802L); + + when(processedRepo.existsById(any())).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s99)); + when(messageRepo.findById(802L)).thenReturn(Optional.of(parentInOtherSession)); + + QuestionsCallbackPayload p = new QuestionsCallbackPayload( + 99L, "FOLLOWUP", "C", "?", 802L, null, null); + service.apply(envelope("mid-cross", p)); + + verify(messageRepo, never()).save(any(InterviewMessage.class)); + verify(processedRepo).save(any()); + } + + /** + * IN_PROGRESS 세션을 만들고 incrementQuestionCount() 를 count 번 호출해 준다. + * (markInProgress → IN_PROGRESS 전이 후 count 만큼 증가) + */ + private InterviewSession inProgressWith(Long sessionId, int count, int max) { + User user = User.createGithubUser(1L, "u", "u@e.com", "url", "tok"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "t", null, SessionMode.ONLINE, InterviewType.TECHNICAL, + JobCategory.BACKEND, max, 60); + ReflectionTestUtils.setField(s, "id", sessionId); + s.markInProgress(); + for (int i = 0; i < count; i++) { + s.incrementQuestionCount(); + } + return s; + } + + private InterviewMessage intervieweeMessage(InterviewSession session, int seq, Long id) { + InterviewMessage m = InterviewMessage.interviewee(session, seq, "답변입니다.", null); + ReflectionTestUtils.setField(m, "id", id); + return m; + } + + private QuestionsCallbackEnvelope envelope(String mid, QuestionsCallbackPayload p) { + return new QuestionsCallbackEnvelope(mid, "callback.questions", "v1", + "trace-1", Instant.now(), "ai-server", p, + new MessageContext(1L, 99L, null, null)); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionQueryServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionQueryServiceTest.java new file mode 100644 index 00000000..55a21f16 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionQueryServiceTest.java @@ -0,0 +1,134 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.exception.SessionForbiddenException; +import com.stackup.stackup.session.application.exception.SessionNotFoundException; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SessionQueryServiceTest { + + @Mock InterviewSessionRepository sessionRepo; + @Mock InterviewMessageRepository messageRepo; + @InjectMocks SessionQueryService service; + + @Test + void get_returns_session_when_owned() { + InterviewSession s = inProgress(99L, 1L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + SessionResult r = service.get(99L, 1L); + assertThat(r.id()).isEqualTo(99L); + } + + @Test + void get_throws_forbidden_when_other_user() { + InterviewSession s = inProgress(99L, 2L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + assertThatThrownBy(() -> service.get(99L, 1L)) + .isInstanceOf(SessionForbiddenException.class); + } + + @Test + void get_throws_not_found_when_session_missing() { + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.get(99L, 1L)) + .isInstanceOf(SessionNotFoundException.class); + } + + @Test + void list_paginates_sessions_by_user() { + when(sessionRepo.findByUser_IdAndIsDeletedFalseOrderByCreatedAtDesc(eq(1L), any())) + .thenReturn(new PageImpl<>(List.of(inProgress(99L, 1L)))); + Page p = service.list(1L, PageRequest.of(0, 20)); + assertThat(p.getTotalElements()).isEqualTo(1); + } + + @Test + void getMessages_returns_sequence_ordered() { + InterviewSession s = inProgress(99L, 1L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(messageRepo.findBySession_IdOrderBySequenceNumberAsc(99L)) + .thenReturn(List.of(interviewer(s, 1, 501L), interviewee(s, 2, 502L))); + List ms = service.getMessages(99L, 1L); + assertThat(ms).hasSize(2); + assertThat(ms.get(0).sequenceNumber()).isEqualTo(1); + } + + @Test + void getMessages_throws_forbidden_when_other_user() { + InterviewSession s = inProgress(99L, 2L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + assertThatThrownBy(() -> service.getMessages(99L, 1L)) + .isInstanceOf(SessionForbiddenException.class); + } + + @Test + void getMessages_throws_not_found_when_session_missing() { + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.getMessages(99L, 1L)) + .isInstanceOf(SessionNotFoundException.class); + } + + + private User userStub(Long id) { + User user = User.createGithubUser( + 123L, "stub-user", "stub@example.com", null, "encrypted-token"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private InterviewSession sessionStub(Long sessionId, Long userId, SessionStatus status) { + User user = userStub(userId); + InterviewSession s = InterviewSession.create( + user, "Test Session", null, + SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND, + 10, 60); + ReflectionTestUtils.setField(s, "id", sessionId); + ReflectionTestUtils.setField(s, "status", status); + return s; + } + + private InterviewSession inProgress(Long sessionId, Long userId) { + return sessionStub(sessionId, userId, SessionStatus.IN_PROGRESS); + } + + private InterviewMessage interviewer(InterviewSession session, int seq, Long id) { + InterviewMessage m = InterviewMessage.interviewer(session, seq, "면접 질문입니다.", null); + ReflectionTestUtils.setField(m, "id", id); + return m; + } + + private InterviewMessage interviewee(InterviewSession session, int seq, Long id) { + InterviewMessage m = InterviewMessage.interviewee(session, seq, "답변입니다.", null); + ReflectionTestUtils.setField(m, "id", id); + return m; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceEndTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceEndTest.java new file mode 100644 index 00000000..38938450 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceEndTest.java @@ -0,0 +1,125 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.exception.SessionForbiddenException; +import com.stackup.stackup.session.application.exception.SessionInvalidStateException; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SessionServiceEndTest { + + @Mock InterviewSessionRepository sessionRepo; + @Mock SessionContextRepository contextRepo; + @Mock InterviewMessageRepository messageRepo; + @Mock AnalyzedDocumentRepository documentRepo; + @Mock UserRepository userRepo; + @Mock ProcessedMessageRepository processedRepo; + @Mock SseEventPublisher sse; + @Mock RabbitMessagePublisher publisher; + @Mock RabbitMqProperties properties; + @Mock ApplicationEventPublisher events; + + @InjectMocks SessionService service; + + @Test + void end_in_progress_completes_session_and_pushes_state() { + InterviewSession s = inProgress(99L, 1L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + SessionResult r = service.end(99L, 1L, false); + + assertThat(r.status()).isEqualTo(SessionStatus.COMPLETED); + assertThat(s.getEndedAt()).isNotNull(); + verify(sse).publishToSession(eq(99L), eq(SseEventType.SESSION_STATE), any()); + } + + @Test + void end_with_cancel_true_sets_cancelled_status() { + InterviewSession s = inProgress(99L, 1L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + SessionResult r = service.end(99L, 1L, true); + + assertThat(r.status()).isEqualTo(SessionStatus.CANCELLED); + } + + @Test + void end_other_users_session_throws_forbidden() { + InterviewSession s = inProgress(99L, 2L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.end(99L, 1L, false)) + .isInstanceOf(SessionForbiddenException.class); + } + + @Test + void end_throws_invalid_state_when_already_completed() { + InterviewSession s = sessionStub(99L, 1L, SessionStatus.COMPLETED); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.end(99L, 1L, false)) + .isInstanceOf(SessionInvalidStateException.class); + } + + @Test + void end_throws_invalid_state_when_already_cancelled() { + InterviewSession s = sessionStub(99L, 1L, SessionStatus.CANCELLED); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.end(99L, 1L, false)) + .isInstanceOf(SessionInvalidStateException.class); + } + + private User userStub(Long id) { + User user = User.createGithubUser( + 123L, "stub-user", "stub@example.com", null, "encrypted-token"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private InterviewSession sessionStub(Long sessionId, Long userId, SessionStatus status) { + User user = userStub(userId); + InterviewSession s = InterviewSession.create( + user, "Test Session", null, + SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND, + 10, 60); + ReflectionTestUtils.setField(s, "id", sessionId); + ReflectionTestUtils.setField(s, "status", status); + return s; + } + + private InterviewSession inProgress(Long sessionId, Long userId) { + return sessionStub(sessionId, userId, SessionStatus.IN_PROGRESS); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceSubmitAnswerTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceSubmitAnswerTest.java new file mode 100644 index 00000000..28e5e094 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceSubmitAnswerTest.java @@ -0,0 +1,193 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.MessageSubmitCommand; +import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; +import com.stackup.stackup.session.application.exception.SessionForbiddenException; +import com.stackup.stackup.session.application.exception.SessionInvalidStateException; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.InterviewType; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SessionServiceSubmitAnswerTest { + + @Mock InterviewSessionRepository sessionRepo; + @Mock SessionContextRepository contextRepo; + @Mock InterviewMessageRepository messageRepo; + @Mock AnalyzedDocumentRepository documentRepo; + @Mock UserRepository userRepo; + @Mock ProcessedMessageRepository processedRepo; + @Mock SseEventPublisher sse; + @Mock RabbitMessagePublisher publisher; + @Mock RabbitMqProperties properties; + @Mock ApplicationEventPublisher events; + + @InjectMocks SessionService service; + + @Test + void submitAnswer_inserts_interviewee_message_and_publishes_event() { + InterviewSession s = inProgress(99L, 1L); + InterviewMessage lastQ = interviewerMessage(s, 1, 501L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(messageRepo.findMaxSequenceBySessionId(99L)).thenReturn(1); + when(messageRepo.findFirstBySession_IdOrderBySequenceNumberDesc(99L)).thenReturn(Optional.of(lastQ)); + when(messageRepo.save(any())).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 502L); + return m; + }); + when(processedRepo.existsById("session.answer:99:idem-uuid")).thenReturn(false); + + MessageResult r = service.submitAnswer(new MessageSubmitCommand( + 99L, 1L, "내 답변", "idem-uuid")); + + assertThat(r.id()).isEqualTo(502L); + assertThat(r.sequenceNumber()).isEqualTo(2); + assertThat(r.role()).isEqualTo(MessageRole.INTERVIEWEE); + verify(events).publishEvent(any(AnswerSubmittedEvent.class)); + verify(processedRepo).save(argThat(pm -> + pm.getMessageId().equals("session.answer:99:idem-uuid"))); + } + + @Test + void submitAnswer_returns_cached_message_when_idempotency_key_duplicate() { + InterviewSession s = inProgress(99L, 1L); + InterviewMessage cached = intervieweeMessage(s, 2, 502L); + when(processedRepo.existsById("session.answer:99:idem-uuid")).thenReturn(true); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(messageRepo.findFirstBySession_IdOrderBySequenceNumberDesc(99L)) + .thenReturn(Optional.of(cached)); + + MessageResult r = service.submitAnswer(new MessageSubmitCommand( + 99L, 1L, "내 답변", "idem-uuid")); + + assertThat(r.id()).isEqualTo(502L); + verify(messageRepo, never()).save(any()); + verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); + } + + @Test + void submitAnswer_throws_when_session_not_in_progress() { + InterviewSession s = ready(99L, 1L); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(processedRepo.existsById(anyString())).thenReturn(false); + + assertThatThrownBy(() -> service.submitAnswer(new MessageSubmitCommand(99L, 1L, "A", "k"))) + .isInstanceOf(SessionInvalidStateException.class); + } + + @Test + void submitAnswer_throws_when_user_mismatch() { + InterviewSession s = inProgress(99L, 2L); // 다른 user 소유 + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + // 소유자 불일치 체크는 멱등 체크보다 먼저 실행되므로 processedRepo stub 불필요 + + assertThatThrownBy(() -> service.submitAnswer(new MessageSubmitCommand(99L, 1L, "A", "k"))) + .isInstanceOf(SessionForbiddenException.class); + } + + @Test + void submitAnswer_with_same_key_in_different_sessions_does_not_collide() { + when(processedRepo.existsById("session.answer:88:shared-key")).thenReturn(false); + InterviewSession s88 = inProgress(88L, 1L); + InterviewMessage parent88 = interviewerMessage(s88, 1, 401L); + when(sessionRepo.findByIdAndIsDeletedFalse(88L)).thenReturn(Optional.of(s88)); + when(messageRepo.findMaxSequenceBySessionId(88L)).thenReturn(1); + when(messageRepo.findFirstBySession_IdOrderBySequenceNumberDesc(88L)).thenReturn(Optional.of(parent88)); + when(messageRepo.save(any())).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 402L); + return m; + }); + + MessageResult r = service.submitAnswer(new MessageSubmitCommand(88L, 1L, "다른 세션 답변", "shared-key")); + + assertThat(r.id()).isEqualTo(402L); + verify(processedRepo).save(argThat(pm -> + pm.getMessageId().equals("session.answer:88:shared-key"))); + } + + @Test + void submitAnswer_throws_when_last_message_is_not_interviewer() { + InterviewSession s = inProgress(99L, 1L); + InterviewMessage prevAnswer = intervieweeMessage(s, 2, 502L); // 직전이 답변 + when(processedRepo.existsById(anyString())).thenReturn(false); + when(sessionRepo.findByIdAndIsDeletedFalse(99L)).thenReturn(Optional.of(s)); + when(messageRepo.findFirstBySession_IdOrderBySequenceNumberDesc(99L)).thenReturn(Optional.of(prevAnswer)); + + assertThatThrownBy(() -> service.submitAnswer(new MessageSubmitCommand(99L, 1L, "A", "k"))) + .isInstanceOf(SessionInvalidStateException.class) + .hasMessageContaining("질문"); + } + + private User userStub(Long id) { + User user = User.createGithubUser( + 123L, "stub-user", "stub@example.com", null, "encrypted-token"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private InterviewSession sessionStub(Long sessionId, Long userId, SessionStatus status) { + User user = userStub(userId); + InterviewSession s = InterviewSession.create( + user, "Test Session", null, + SessionMode.ONLINE, InterviewType.TECHNICAL, JobCategory.BACKEND, + 10, 60); + ReflectionTestUtils.setField(s, "id", sessionId); + ReflectionTestUtils.setField(s, "status", status); + return s; + } + + private InterviewSession inProgress(Long sessionId, Long userId) { + return sessionStub(sessionId, userId, SessionStatus.IN_PROGRESS); + } + + private InterviewSession ready(Long sessionId, Long userId) { + return sessionStub(sessionId, userId, SessionStatus.READY); + } + + private InterviewMessage interviewerMessage(InterviewSession session, int seq, Long id) { + InterviewMessage m = InterviewMessage.interviewer(session, seq, "면접 질문입니다.", null); + ReflectionTestUtils.setField(m, "id", id); + return m; + } + + private InterviewMessage intervieweeMessage(InterviewSession session, int seq, Long id) { + InterviewMessage m = InterviewMessage.interviewee(session, seq, "답변입니다.", null); + ReflectionTestUtils.setField(m, "id", id); + return m; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerSubmitAnswerTest.java b/backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerSubmitAnswerTest.java new file mode 100644 index 00000000..fbed279c --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/presentation/SessionControllerSubmitAnswerTest.java @@ -0,0 +1,84 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.SessionQueryService; +import com.stackup.stackup.session.application.SessionService; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class SessionControllerSubmitAnswerTest { + + @Mock + SessionService service; + + @Mock + SessionQueryService queryService; + + MockMvc mvc; + + private static final UserPrincipal TEST_PRINCIPAL = + new UserPrincipal(1L, "octocat", List.of()); + + @BeforeEach + void setUp() { + SessionController controller = new SessionController(service, queryService); + mvc = MockMvcBuilders + .standaloneSetup(controller) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(TEST_PRINCIPAL, null, List.of()) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void post_message_with_idempotency_key_returns_201() throws Exception { + MessageResult r = new MessageResult(502L, 99L, 2, MessageRole.INTERVIEWEE, + "A", 501L, MessageStatus.COMPLETED, Instant.now()); + when(service.submitAnswer(any())).thenReturn(r); + + mvc.perform(post("/api/sessions/99/messages") + .header("Idempotency-Key", "uuid-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"내 답변\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(502)) + .andExpect(jsonPath("$.role").value("INTERVIEWEE")); + } + + @Test + void post_message_blank_content_returns_400() throws Exception { + mvc.perform(post("/api/sessions/99/messages") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"\"}")) + .andExpect(status().isBadRequest()); + } +} diff --git a/backend/src/test/resources/application-it.yml b/backend/src/test/resources/application-it.yml new file mode 100644 index 00000000..d5f0fd8f --- /dev/null +++ b/backend/src/test/resources/application-it.yml @@ -0,0 +1,106 @@ +# Integration-test profile — used by @ActiveProfiles("it") tests with Testcontainers. +# DataSource / JPA / Flyway ARE enabled (contrast with the "test" profile in +# src/main/resources/application-test.yml which disables them for unit tests). +# Connection coordinates (DB, RabbitMQ) are overridden at runtime by @DynamicPropertySource. + +spring: + jpa: + hibernate: + ddl-auto: validate # rely on Flyway to create the schema + properties: + hibernate: + format_sql: false + flyway: + enabled: true + locations: classpath:db/migration + +app: + security: + jwt-secret: it-jwt-secret-at-least-32-chars-long + encryption-key: it-encryption-key-32-chars-long! + access-token-ttl-seconds: 900 + refresh-token-ttl-seconds: 1209600 + access-token-type: Bearer + oauth-state-ttl: 5m + oauth-state-byte-length: 32 + pkce-code-verifier-byte-length: 32 + refresh-token-byte-length: 32 + refresh-token-cookie-name: refresh_token + refresh-token-cookie-path: /api/auth + refresh-token-cookie-same-site: Strict + refresh-token-cookie-secure: false + refresh-token-cookie-http-only: true + internal-api-key: it-internal-api-key + + github: + client-id: it-github-client-id + client-secret: it-github-client-secret + redirect-uri: http://localhost:5173/auth/callback + scopes: read:user user:email repo + oauth-base-url: https://github.com + token-type: bearer + code-challenge-method: S256 + api-base-url: https://api.github.com + api-version: 2022-11-28 + connect-timeout: 3s + read-timeout: 5s + + s3: + endpoint: http://localhost:9000 # unused in tests; S3ObjectStorageClient is not called + access-key: minioadmin + secret-key: minioadmin + bucket: stackup + region: us-east-1 + path-style: true + + messaging: + rabbitmq: + publisher: core-server + version: v1 + message: + content-type: application/json + content-encoding: UTF-8 + trace-id-header: x-trace-id + template: + mandatory: false # mandatory=false avoids ReturnCallback issues in tests + exchanges: + durable: true + auto-delete: false + names: + core-to-ai: stackup.core-to-ai + ai-to-core: stackup.ai-to-core + realtime: stackup.realtime + queues: + durable: true + names: + ai-analyze-resume: ai.analyze.resume + ai-analyze-repository: ai.analyze.repository + ai-generate-questions: ai.generate.questions + ai-generate-followup: ai.generate.followup + core-callback-analysis: core.callback.analysis + core-callback-questions: core.callback.questions + routing-keys: + analyze-resume: analyze.resume + analyze-repository: analyze.repository + generate-questions: generate.questions + generate-followup: generate.followup + callback-analysis: callback.analysis + callback-questions: callback.questions + realtime-session-notify: realtime.session.notify + dead-letter: + exchange: stackup.dlx + routing-key-prefix: dlq. + retry: + max-retries: 3 + initial-interval: 1s + multiplier: 2.0 + max-interval: 10s + + sse: + timeout: 30m + keep-alive-interval: 30s + stream-token-ttl: 60s + + cors: + allowed-origins: + - http://localhost:5173 diff --git a/docs/data-flow.md b/docs/data-flow.md index c964d0cc..02a5b621 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -56,16 +56,15 @@ → session_contexts INSERT (선택된 analyzed_documents 연결) → [Core] RabbitMQ publish: stackup.core-to-ai / generate.questions → [AI] RAG 검색 (pgvector 유사도, Core API 경유) → 컨텍스트 추출 - → [AI] Gemini 3.1 Pro → 질문 풀 생성 (10~15개) - → [AI] RabbitMQ publish: stackup.ai-to-core / callback.questions (kind=POOL) - → [Core] 질문 풀을 interview_sessions row 또는 별도 테이블에 저장 - (Redis 미사용 — 세션 단위 데이터이므로 PG로 충분) + → [AI] Gemini 3.1 Pro → 첫 질문 1개 생성 (RAG 컨텍스트 기반) + → [AI] RabbitMQ publish: stackup.ai-to-core / callback.questions (kind=FIRST) + → [Core] interview_messages INSERT (첫 INTERVIEWER 메시지) + session.markInProgress() + SSE push ``` ### 3.2 질문-답변 사이클 (반복) ``` -[Core/RealTime] SSE → 첫 질문 push +[Core] SSE → 첫 질문 push (kind=FIRST 콜백 처리 후) → [Frontend] 표시 + 마이크 활성화 → [사용자] 음성 답변 (Phase 2) 또는 텍스트 → [Frontend] (음성 모드) WebRTC stream → RealTime @@ -76,18 +75,26 @@ → [Core] RabbitMQ publish: stackup.core-to-ai / generate.followup → [AI] 답변 평가 + 꼬리질문 생성 (Gemini 3.1 Flash + RAG) → 답변이 음성이면 음성 분석도 병행 - → [AI] RabbitMQ publish: stackup.ai-to-core / callback.questions (kind=FOLLOWUP) - → [Core] interview_messages INSERT (role=INTERVIEWER, 꼬리질문) - → message_voice_analyses INSERT (음성 모드일 경우) - → [Core] SSE → 다음 질문 push + → [AI] RabbitMQ publish: stackup.ai-to-core / callback.questions (kind=FOLLOWUP 또는 kind=END) + → [Core] kind=FOLLOWUP: interview_messages INSERT (role=INTERVIEWER, 꼬리질문) + + message_voice_analyses INSERT (음성 모드일 경우) + + max 도달 시 자동 종료 + SSE push + kind=END: AI 조기 종료 신호 → session.end() + SSE STATE push + → [Core] SSE SESSION_MESSAGE → 다음 질문 push ``` ### 3.3 세션 종료 +세션은 다음 세 경로로 종료된다: + +1. **최대 질문 수 도달** — FOLLOWUP 콜백 처리 시 Core 카운터가 maxQuestions 도달을 감지 → 자동으로 `session.end()` 호출 + SSE SESSION_STATE push +2. **AI `kind=END` 조기 종료 신호** — AI가 답변 품질 등을 판단해 FOLLOWUP 대신 END 신호 전송 → Core `session.end()` + SSE SESSION_STATE push +3. **사용자 수동 종료** — `POST /api/sessions/{id}/end` 호출 → 즉시 COMPLETED 또는 CANCELLED 처리 + ``` -[사용자] 종료 버튼 OR 최대 질문/시간 도달 +(공통 종료 이후) → [Core] interview_sessions.status = COMPLETED, ended_at = now() - → [Core] RabbitMQ publish: stackup.core-to-ai / generate.feedback (예정) + → [Core] RabbitMQ publish: stackup.core-to-ai / generate.feedback (예정 — Sprint 3) → [AI] 전체 메시지 + 음성 분석 → 종합 평가 (Gemini 3.1 Pro) → [AI] S3 PUT: feedback/{session_id}/report.md → [AI] RabbitMQ publish: stackup.ai-to-core / callback.feedback (예정) diff --git a/docs/messaging.md b/docs/messaging.md index eaf8446c..5f8af9fc 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -119,7 +119,7 @@ | 이력서(PDF) 분석 (US-09) | `analyze.resume` | `callback.analysis` | `core.callback.analysis` | | 웹 이력서(URL) 분석 (US-09) | `analyze.web` | `callback.analysis` | `core.callback.analysis` | | 레포 분석 (US-10) | `analyze.repository` | `callback.analysis` | `core.callback.analysis` | -| 질문 풀 생성 (US-18) | `generate.questions` | `callback.questions` | `core.callback.questions` | +| 첫 질문 생성 (US-18) | `generate.questions` | `callback.questions` | `core.callback.questions` | | 꼬리질문 생성 (US-19) | `generate.followup` | `callback.questions` | `core.callback.questions` | | 피드백 생성 (US-24) | `generate.feedback` *(예정)* | `callback.feedback` *(예정)* | `core.callback.feedback` *(예정)* | | 세션 알림 (RT2 SSE) | `realtime.session.notify` | (없음 — 단방향 push) | `q.realtime.session.notify` | @@ -229,17 +229,15 @@ } ``` -### 5.7 `callback.questions` (질문 풀) +### 5.7 `callback.questions` (첫 질문) ```json { "messageType": "callback.questions", "payload": { "sessionId": 99, - "kind": "POOL", - "questions": [ - { "category": "PROJECT_DEEP_DIVE", "question": "..." }, - { "category": "CS_FUNDAMENTAL", "question": "..." } - ] + "kind": "FIRST", + "category": "PROJECT_DEEP_DIVE", + "question": "왜 그 아키텍처를 선택했나요?" } } ``` @@ -266,22 +264,26 @@ "sessionId": 99, "kind": "FOLLOWUP", "parentMessageId": 502, - "followupQuestion": "...", + "category": "CS_FUNDAMENTAL", + "question": "왜 그것이 더 좋은가요?", "answerEvaluation": { "specificity": 3.5, "logic": 4.0, "structure": "PARTIAL_STAR" }, - "voiceAnalysis": { - "speakingRateWpm": 142.0, - "fillerWordCounts": { "음": 5, "어": 3 }, - "silenceDurationSec": 8.2 - } + "voiceAnalysis": null } } ``` -> `callback.questions` 큐는 두 종류(`POOL`, `FOLLOWUP`)를 받으므로 consumer는 `payload.kind`로 분기. +조기 종료 신호: +```json +{ "messageType": "callback.questions", + "payload": { "sessionId": 99, "kind": "END", + "answerEvaluation": { "specificity": 4.1 } } } +``` + +> `callback.questions` 큐는 세 종류(`FIRST`, `FOLLOWUP`, `END`)를 받으므로 consumer는 `payload.kind`로 분기. ### 5.10 `generate.feedback` *(예정)* ```json