|
| 1 | +package com.stackup.stackup.session.application; |
| 2 | + |
| 3 | +import com.stackup.stackup.common.exception.ApiErrorCode; |
| 4 | +import com.stackup.stackup.common.exception.DomainException; |
| 5 | +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; |
| 6 | +import com.stackup.stackup.common.sse.SseEventType; |
| 7 | +import com.stackup.stackup.session.application.dto.SessionResult; |
| 8 | +import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; |
| 9 | +import com.stackup.stackup.session.domain.InterviewMessage; |
| 10 | +import com.stackup.stackup.session.domain.InterviewMessageRepository; |
| 11 | +import com.stackup.stackup.session.domain.InterviewSession; |
| 12 | +import com.stackup.stackup.session.domain.InterviewSessionRepository; |
| 13 | +import com.stackup.stackup.session.domain.MessageRole; |
| 14 | +import com.stackup.stackup.session.domain.SessionContextRepository; |
| 15 | +import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; |
| 16 | +import com.stackup.stackup.session.domain.SessionStatus; |
| 17 | +import java.time.Instant; |
| 18 | +import java.util.ArrayList; |
| 19 | +import java.util.List; |
| 20 | +import lombok.RequiredArgsConstructor; |
| 21 | +import org.slf4j.Logger; |
| 22 | +import org.slf4j.LoggerFactory; |
| 23 | +import org.springframework.context.ApplicationEventPublisher; |
| 24 | +import org.springframework.stereotype.Service; |
| 25 | +import org.springframework.transaction.annotation.Transactional; |
| 26 | + |
| 27 | +/** |
| 28 | + * 중단된 면접 이어하기 (US-17 확장). |
| 29 | + * |
| 30 | + * <p>상태를 되돌리는 것만으로는 부족하다. 중단은 보통 <b>턴 한가운데</b>에서 일어나고, |
| 31 | + * 그동안 도착한 콜백은 terminal 가드가 전부 드롭했다. 그대로 재개하면 사용자는 답할 질문이 |
| 32 | + * 없거나 "(생성 중)" 에 멈춰 있는 화면을 본다. 그래서 재개는 두 단계다: |
| 33 | + * <b>원자적 상태 전이 + 끊긴 턴 복구</b>. |
| 34 | + */ |
| 35 | +@Service |
| 36 | +@RequiredArgsConstructor |
| 37 | +public class SessionResumeService { |
| 38 | + |
| 39 | + private static final Logger log = LoggerFactory.getLogger(SessionResumeService.class); |
| 40 | + private static final String RESUME_REASON = "RESUMED"; |
| 41 | + |
| 42 | + private final InterviewSessionRepository sessionRepository; |
| 43 | + private final InterviewMessageRepository messageRepository; |
| 44 | + private final SessionContextRepository contextRepository; |
| 45 | + private final SessionQuestionPoolRepository poolRepository; |
| 46 | + private final QuestionsCallbackService questionsCallbackService; |
| 47 | + private final ApplicationEventPublisher events; |
| 48 | + |
| 49 | + @Transactional |
| 50 | + public SessionResult resume(Long userId, Long sessionId) { |
| 51 | + InterviewSession session = sessionRepository |
| 52 | + .findByIdAndUser_IdAndDeletedFalse(sessionId, userId) |
| 53 | + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); |
| 54 | + |
| 55 | + // 이어할 수 있는 건 중단된 세션뿐이다. 완료 세션은 피드백이 이미 나갔고, |
| 56 | + // 취소 세션은 시작한 적이 없다(둘 다 '다시 하기'로 새 세션을 만드는 게 맞다). |
| 57 | + if (session.getStatus() != SessionStatus.INTERRUPTED) { |
| 58 | + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); |
| 59 | + } |
| 60 | + // 원자적 재개 전이 — 중복 요청 중 하나만 차지한다(다른 전이와 같은 패턴). |
| 61 | + if (sessionRepository.resumeIfInterrupted(sessionId, Instant.now()) == 0) { |
| 62 | + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); |
| 63 | + } |
| 64 | + // 조건부 UPDATE 는 영속성 컨텍스트를 우회하므로 엔티티를 다시 읽는다. |
| 65 | + sessionRepository.flush(); |
| 66 | + InterviewSession resumed = sessionRepository.findById(sessionId).orElseThrow(); |
| 67 | + |
| 68 | + recoverTurn(userId, resumed); |
| 69 | + publishState(resumed); |
| 70 | + log.info("session resumed. sessionId={}, userId={}", sessionId, userId); |
| 71 | + return SessionResult.of(resumed, contextDocumentIds(sessionId)); |
| 72 | + } |
| 73 | + |
| 74 | + /** |
| 75 | + * 끊긴 턴을 이어붙인다. 마지막 메시지가 무엇이냐로 갈린다. |
| 76 | + * |
| 77 | + * <ul> |
| 78 | + * <li>정상 질문 → 할 일 없음. 사용자가 그 질문에 답하면 된다. |
| 79 | + * <li>생성 중 placeholder → 그 꼬리질문은 영영 오지 않는다(콜백이 드롭됐다). |
| 80 | + * 실패로 확정하고 다음 일반질문으로 넘긴다. |
| 81 | + * <li>자기소개 답변인데 질문 풀이 없음 → 풀 생성 요청이 유실된 것. 다시 요청한다. |
| 82 | + * <li>그 외 답변 → 다음 질문이 오지 않은 것. 다음 일반질문으로 넘긴다. |
| 83 | + * </ul> |
| 84 | + */ |
| 85 | + private void recoverTurn(Long userId, InterviewSession session) { |
| 86 | + InterviewMessage last = messageRepository |
| 87 | + .findFirstBySession_IdOrderBySequenceNumberDesc(session.getId()) |
| 88 | + .orElse(null); |
| 89 | + if (last == null) { |
| 90 | + log.warn("resume: session has no messages — nothing to recover. sessionId={}", |
| 91 | + session.getId()); |
| 92 | + return; |
| 93 | + } |
| 94 | + |
| 95 | + if (last.getRole() == MessageRole.INTERVIEWER) { |
| 96 | + if (!isPendingPlaceholder(last)) { |
| 97 | + return; // 답할 질문이 그대로 있다 |
| 98 | + } |
| 99 | + log.info("resume: dangling followup placeholder — failing and advancing. sessionId={}, msg={}", |
| 100 | + session.getId(), last.getId()); |
| 101 | + last.failFollowup(); |
| 102 | + questionsCallbackService.advanceToNextGeneral(session.getId()); |
| 103 | + return; |
| 104 | + } |
| 105 | + |
| 106 | + // 마지막이 답변 = 다음 질문이 오지 않은 상태. |
| 107 | + InterviewMessage parent = last.getParentMessage(); |
| 108 | + boolean selfIntroAnswer = parent != null && parent.isSelfIntroduction(); |
| 109 | + if (selfIntroAnswer && poolRepository.countBySessionId(session.getId()) == 0) { |
| 110 | + log.info("resume: question pool never generated — re-requesting. sessionId={}", |
| 111 | + session.getId()); |
| 112 | + requestQuestionPool(userId, session, last.getContent()); |
| 113 | + return; |
| 114 | + } |
| 115 | + log.info("resume: answer without next question — advancing. sessionId={}", session.getId()); |
| 116 | + questionsCallbackService.advanceToNextGeneral(session.getId()); |
| 117 | + } |
| 118 | + |
| 119 | + // 내용이 아직 채워지지 않은 꼬리질문 placeholder 인지. |
| 120 | + private boolean isPendingPlaceholder(InterviewMessage message) { |
| 121 | + return InterviewMessage.FOLLOWUP_GENERATING_TEXT.equals(message.getContent()); |
| 122 | + } |
| 123 | + |
| 124 | + // SessionFollowupRequester 가 자기소개 답변 직후 내는 것과 같은 이벤트. |
| 125 | + // AFTER_COMMIT 리스너(SessionQuestionsRequester)가 받아 generate.questions 를 발행한다. |
| 126 | + private void requestQuestionPool(Long userId, InterviewSession session, String selfIntroAnswer) { |
| 127 | + events.publishEvent(new SelfIntroAnsweredEvent( |
| 128 | + userId, |
| 129 | + session.getId(), |
| 130 | + session.getMode(), |
| 131 | + new ArrayList<>(session.getJobCategories()), |
| 132 | + session.getMaxQuestions(), |
| 133 | + session.getGeneralQuestionCount(), |
| 134 | + contextDocumentIds(session.getId()), |
| 135 | + selfIntroAnswer, |
| 136 | + session.getTargetCompanyName(), |
| 137 | + session.getTargetJobDescription() |
| 138 | + )); |
| 139 | + } |
| 140 | + |
| 141 | + private void publishState(InterviewSession session) { |
| 142 | + SessionTimeoutService.SessionStateNotice notice = new SessionTimeoutService.SessionStateNotice( |
| 143 | + session.getId(), SessionStatus.IN_PROGRESS.name(), RESUME_REASON); |
| 144 | + events.publishEvent(RealtimeNotifyEvent.session( |
| 145 | + session.getId(), SseEventType.SESSION_STATE, notice)); |
| 146 | + events.publishEvent(RealtimeNotifyEvent.user( |
| 147 | + session.getUser().getId(), SseEventType.SESSION_STATE, notice)); |
| 148 | + } |
| 149 | + |
| 150 | + private List<Long> contextDocumentIds(Long sessionId) { |
| 151 | + return contextRepository.findBySession_Id(sessionId).stream() |
| 152 | + .map(c -> c.getDocument().getId()) |
| 153 | + .toList(); |
| 154 | + } |
| 155 | +} |
0 commit comments