From c07b154d150e406a234e7ecd0941133f57cc8138 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 15:10:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(backend):=20=EC=A4=91=EB=8B=A8?= =?UTF-8?q?=EB=90=9C=20=EB=A9=B4=EC=A0=91=20=EC=9D=B4=EC=96=B4=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20(B-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PATCH /api/sessions/{id}/resume` — INTERRUPTED 세션을 다시 진행 가능한 상태로 되돌린다. 완료·취소 세션은 이어할 수 없다(422). 새로 하려면 기존 `/retry` 를 쓴다. ## 핵심은 상태 전이가 아니라 끊긴 턴 복구다 중단은 보통 **턴 한가운데**에서 일어난다(탭 종료·네트워크 끊김·수동 중단). 그동안 도착한 콜백은 terminal 가드가 전부 드롭했으므로, 상태만 IN_PROGRESS 로 되돌리면 사용자는 답할 질문이 없거나 "(생성 중)" 에 멈춘 화면을 본다. `SessionResumeService.recoverTurn` 이 마지막 메시지로 분기한다: | 마지막 메시지 | 조치 | |---|---| | 정상 질문 | 없음 — 그 질문에 답하면 된다 | | "(생성 중)" placeholder | `failFollowup` + 다음 일반질문 (그 꼬리질문은 영영 안 온다) | | 자기소개 답변 + 풀 0건 | `SelfIntroAnsweredEvent` 재발행 (풀 생성 요청이 유실된 것) | | 그 외 답변 | 다음 일반질문 | 자기소개 분기가 중요하다. 풀이 없는데 `advanceToNextGeneral` 로 넘기면 POOL_EXHAUSTED 로 세션이 그대로 끝나버린다. ## 시간 한도 `max_duration_minutes` 를 `started_at` 기준으로 재고 있어서, 한참 뒤에 재개하면 스위퍼가 즉시 다시 중단시킨다. V27 로 `resumed_at` 을 추가하고 스위퍼가 `durationAnchor()`(= resumedAt ?? startedAt) 기준으로 재도록 했다 — 이어하기마다 그 자리의 시간이 새로 시작된다. `started_at` 은 '처음 시작한 시각'으로 보존된다(히스토리 표시). 이어하기를 반복하면 총 시간이 늘어난다. 연습 도구라 제한하지 않았다. ## 전이 `resumeIfInterrupted` 조건부 UPDATE 로 전이를 차지하고 `ended_at` 을 지운다. 다른 전이 (start/cancel/finish)와 같은 패턴이라 중복 요청 중 하나만 1을 받는다. ## 테스트 `SessionResumeServiceTest` 신설 (8) — 완료 세션 거부, 타인 세션 거부, 전이 경합, 그리고 복구 분기 5종(정상 질문 무개입, placeholder 실패 처리, 답변 후 다음 질문, 자기소개+풀 없음 → 재요청, 자기소개+풀 있음 → 다음 질문). --- backend/CLAUDE.md | 11 + backend/openapi.json | 59 +++++ .../application/SessionResumeService.java | 155 +++++++++++++ .../application/SessionTimeoutSweeper.java | 8 +- .../session/domain/InterviewSession.java | 10 + .../domain/InterviewSessionRepository.java | 9 + .../presentation/SessionController.java | 24 ++ .../migration/V27__add_session_resumed_at.sql | 6 + .../application/SessionResumeServiceTest.java | 212 ++++++++++++++++++ 9 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java create mode 100644 backend/src/main/resources/db/migration/V27__add_session_resumed_at.sql create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 24890220..7036fbd0 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -465,6 +465,17 @@ docker compose up -d `findByParentMessage_IdIn` 으로 한 번에 받아 매핑한다(질문마다 조회하면 N+1). `QuestionBookmarkController` 는 URL 이 `/api/users/me/*` 지만 `UserStatsController` 와 같은 이유로 session 슬라이스에 둔다(user → session 직접 의존 회피). +- **중단 세션 이어하기 본 구현 (B-5)**: `PATCH /api/sessions/{id}/resume` — INTERRUPTED 만 재개 + 가능(완료·취소는 422, 새로 하려면 `/retry`). `resumeIfInterrupted` 조건부 UPDATE 로 전이를 + 차지하고 `ended_at` 을 지우며 `resumed_at`(V27)을 찍는다. + - **시간 한도 기준을 `durationAnchor()`(= resumedAt ?? startedAt) 로 바꿨다.** startedAt 기준 + 그대로면 한참 뒤 재개했을 때 스위퍼가 즉시 다시 중단시킨다. startedAt 은 '처음 시작한 시각' + 으로 보존된다. 이어하기를 반복하면 총 시간이 늘어나지만, 연습 도구라 허용하는 트레이드오프. + - **핵심은 전이가 아니라 끊긴 턴 복구다**(`SessionResumeService.recoverTurn`). 중단은 보통 턴 + 한가운데서 일어나고 그동안 온 콜백은 terminal 가드가 전부 드롭했다. 마지막 메시지로 분기: + 정상 질문이면 그대로(답하면 됨) / "(생성 중)" placeholder 면 `failFollowup` + 다음 일반질문 / + 자기소개 답변인데 풀이 0건이면 `SelfIntroAnsweredEvent` 재발행(넘기면 POOL_EXHAUSTED 로 + 세션이 끝나버린다) / 그 외 답변이면 다음 일반질문. - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index a95769c7..b89417df 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1848,6 +1848,65 @@ } } }, + "/api/sessions/{sessionId}/resume" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS)", + "description" : "중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, 질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. 완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다.", + "operationId" : "resumeSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "재개됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "INTERRUPTED 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, "/api/sessions/{sessionId}/interrupt" : { "patch" : { "tags" : [ "Sessions" ], diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java new file mode 100644 index 00000000..441d07b3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java @@ -0,0 +1,155 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; +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.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; +import com.stackup.stackup.session.domain.SessionStatus; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 중단된 면접 이어하기 (US-17 확장). + * + *

상태를 되돌리는 것만으로는 부족하다. 중단은 보통 턴 한가운데에서 일어나고, + * 그동안 도착한 콜백은 terminal 가드가 전부 드롭했다. 그대로 재개하면 사용자는 답할 질문이 + * 없거나 "(생성 중)" 에 멈춰 있는 화면을 본다. 그래서 재개는 두 단계다: + * 원자적 상태 전이 + 끊긴 턴 복구. + */ +@Service +@RequiredArgsConstructor +public class SessionResumeService { + + private static final Logger log = LoggerFactory.getLogger(SessionResumeService.class); + private static final String RESUME_REASON = "RESUMED"; + + private final InterviewSessionRepository sessionRepository; + private final InterviewMessageRepository messageRepository; + private final SessionContextRepository contextRepository; + private final SessionQuestionPoolRepository poolRepository; + private final QuestionsCallbackService questionsCallbackService; + private final ApplicationEventPublisher events; + + @Transactional + public SessionResult resume(Long userId, Long sessionId) { + InterviewSession session = sessionRepository + .findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + + // 이어할 수 있는 건 중단된 세션뿐이다. 완료 세션은 피드백이 이미 나갔고, + // 취소 세션은 시작한 적이 없다(둘 다 '다시 하기'로 새 세션을 만드는 게 맞다). + if (session.getStatus() != SessionStatus.INTERRUPTED) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + // 원자적 재개 전이 — 중복 요청 중 하나만 차지한다(다른 전이와 같은 패턴). + if (sessionRepository.resumeIfInterrupted(sessionId, Instant.now()) == 0) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + // 조건부 UPDATE 는 영속성 컨텍스트를 우회하므로 엔티티를 다시 읽는다. + sessionRepository.flush(); + InterviewSession resumed = sessionRepository.findById(sessionId).orElseThrow(); + + recoverTurn(userId, resumed); + publishState(resumed); + log.info("session resumed. sessionId={}, userId={}", sessionId, userId); + return SessionResult.of(resumed, contextDocumentIds(sessionId)); + } + + /** + * 끊긴 턴을 이어붙인다. 마지막 메시지가 무엇이냐로 갈린다. + * + *

+ */ + private void recoverTurn(Long userId, InterviewSession session) { + InterviewMessage last = messageRepository + .findFirstBySession_IdOrderBySequenceNumberDesc(session.getId()) + .orElse(null); + if (last == null) { + log.warn("resume: session has no messages — nothing to recover. sessionId={}", + session.getId()); + return; + } + + if (last.getRole() == MessageRole.INTERVIEWER) { + if (!isPendingPlaceholder(last)) { + return; // 답할 질문이 그대로 있다 + } + log.info("resume: dangling followup placeholder — failing and advancing. sessionId={}, msg={}", + session.getId(), last.getId()); + last.failFollowup(); + questionsCallbackService.advanceToNextGeneral(session.getId()); + return; + } + + // 마지막이 답변 = 다음 질문이 오지 않은 상태. + InterviewMessage parent = last.getParentMessage(); + boolean selfIntroAnswer = parent != null && parent.isSelfIntroduction(); + if (selfIntroAnswer && poolRepository.countBySessionId(session.getId()) == 0) { + log.info("resume: question pool never generated — re-requesting. sessionId={}", + session.getId()); + requestQuestionPool(userId, session, last.getContent()); + return; + } + log.info("resume: answer without next question — advancing. sessionId={}", session.getId()); + questionsCallbackService.advanceToNextGeneral(session.getId()); + } + + // 내용이 아직 채워지지 않은 꼬리질문 placeholder 인지. + private boolean isPendingPlaceholder(InterviewMessage message) { + return InterviewMessage.FOLLOWUP_GENERATING_TEXT.equals(message.getContent()); + } + + // SessionFollowupRequester 가 자기소개 답변 직후 내는 것과 같은 이벤트. + // AFTER_COMMIT 리스너(SessionQuestionsRequester)가 받아 generate.questions 를 발행한다. + private void requestQuestionPool(Long userId, InterviewSession session, String selfIntroAnswer) { + events.publishEvent(new SelfIntroAnsweredEvent( + userId, + session.getId(), + session.getMode(), + new ArrayList<>(session.getJobCategories()), + session.getMaxQuestions(), + session.getGeneralQuestionCount(), + contextDocumentIds(session.getId()), + selfIntroAnswer, + session.getTargetCompanyName(), + session.getTargetJobDescription() + )); + } + + private void publishState(InterviewSession session) { + SessionTimeoutService.SessionStateNotice notice = new SessionTimeoutService.SessionStateNotice( + session.getId(), SessionStatus.IN_PROGRESS.name(), RESUME_REASON); + events.publishEvent(RealtimeNotifyEvent.session( + session.getId(), SseEventType.SESSION_STATE, notice)); + events.publishEvent(RealtimeNotifyEvent.user( + session.getUser().getId(), SseEventType.SESSION_STATE, notice)); + } + + private List contextDocumentIds(Long sessionId) { + return contextRepository.findBySession_Id(sessionId).stream() + .map(c -> c.getDocument().getId()) + .toList(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java index 6cea72cf..688b8661 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java @@ -52,11 +52,13 @@ public void sweep() { } } + // 기준 시각은 startedAt 이 아니라 durationAnchor() — 이어하기로 재개했다면 그 시각부터 + // 다시 잰다. 아니면 재개하자마자 스위퍼가 즉시 다시 중단시킨다. private boolean isTimedOut(InterviewSession s, Instant now) { - if (s.getStartedAt() == null || s.getMaxDurationMinutes() == null) { + Instant anchor = s.durationAnchor(); + if (anchor == null || s.getMaxDurationMinutes() == null) { return false; } - Instant deadline = s.getStartedAt().plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES); - return now.isAfter(deadline); + return now.isAfter(anchor.plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES)); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index c79bd374..d622d766 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -110,6 +110,11 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Column(name = "ended_at") private Instant endedAt; + // 중단 후 이어하기로 재개한 시각. 시간 한도는 이 값 기준으로 다시 잰다(없으면 startedAt). + // startedAt 은 '처음 시작한 시각'으로 보존한다. + @Column(name = "resumed_at") + private Instant resumedAt; + private InterviewSession(User user, String title, String memo, SessionMode mode, List jobCategories, Integer maxQuestions, Integer maxDurationMinutes, @@ -175,6 +180,11 @@ public void assignTargetRole(String companyName, String jobDescription) { this.targetJobDescription = jobDescription; } + // 시간 한도의 기준 시각. 이어하기로 재개했다면 그 자리(sitting)의 시작이 기준이다. + public Instant durationAnchor() { + return resumedAt != null ? resumedAt : startedAt; + } + public void start() { if (status != SessionStatus.READY) { throw new IllegalStateException("session is not READY to start (current=" + status + ")"); diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index 0cdec811..259bda13 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -58,6 +58,15 @@ int finishIfInProgress(@Param("id") Long id, + "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.READY") int startIfReady(@Param("id") Long id, @Param("now") Instant now); + // 원자적 재개 전이: INTERRUPTED 일 때만 IN_PROGRESS 로 되돌린다. endedAt 을 지우고 + // resumedAt 을 새로 찍어 시간 한도를 이 자리 기준으로 다시 재게 한다. + // 다른 전이와 같은 조건부 UPDATE 패턴 — 중복 요청 중 하나만 1을 받는다. + @Modifying + @Query("update InterviewSession s set s.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS, " + + "s.resumedAt = :now, s.endedAt = null " + + "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.INTERRUPTED") + int resumeIfInterrupted(@Param("id") Long id, @Param("now") Instant now); + // 원자적 취소 전이: READY 일 때만 CANCELLED 로 (동시 start 와의 레이스 차단). @Modifying @Query("update InterviewSession s set s.status = com.stackup.stackup.session.domain.SessionStatus.CANCELLED " 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 420a2696..149db4e2 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 @@ -2,6 +2,7 @@ import com.stackup.stackup.common.response.PageResponse; import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.session.application.SessionResumeService; import com.stackup.stackup.session.application.SessionService; import com.stackup.stackup.session.presentation.dto.SessionCreateRequest; import com.stackup.stackup.session.presentation.dto.SessionResponse; @@ -36,6 +37,7 @@ public class SessionController { private final SessionService sessionService; + private final SessionResumeService resumeService; @Operation( operationId = "createSession", @@ -132,6 +134,28 @@ public SessionResponse update( )); } + @Operation( + operationId = "resumeSession", + summary = "중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS)", + description = "중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 " + + "끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, " + + "질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. " + + "완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "재개됨"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음"), + @ApiResponse(responseCode = "422", description = "INTERRUPTED 아님") + }) + @PatchMapping("/{sessionId}/resume") + public SessionResponse resume( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(resumeService.resume(principal.userId(), sessionId)); + } + @Operation(operationId = "startSession", summary = "세션 시작 (READY→IN_PROGRESS) (US-17)") @ApiResponses({ @ApiResponse(responseCode = "200", description = "시작됨"), diff --git a/backend/src/main/resources/db/migration/V27__add_session_resumed_at.sql b/backend/src/main/resources/db/migration/V27__add_session_resumed_at.sql new file mode 100644 index 00000000..2e4065be --- /dev/null +++ b/backend/src/main/resources/db/migration/V27__add_session_resumed_at.sql @@ -0,0 +1,6 @@ +-- B-5 중단 세션 이어하기. INTERRUPTED → IN_PROGRESS 재개를 허용한다. +-- 시간 한도(max_duration_minutes)는 started_at 기준인데, 한참 뒤에 재개하면 스위퍼가 +-- 즉시 다시 중단시킨다. 재개 시각을 따로 두고 스위퍼가 COALESCE(resumed_at, started_at) +-- 기준으로 재도록 해, 이어하기마다 그 자리(sitting)의 시간이 새로 시작되게 한다. +-- started_at 은 '처음 시작한 시각'으로 보존된다(히스토리 표시용). +ALTER TABLE interview_sessions ADD COLUMN resumed_at TIMESTAMPTZ; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java new file mode 100644 index 00000000..b9e59bba --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java @@ -0,0 +1,212 @@ +package com.stackup.stackup.session.application; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.session.application.dto.SessionResult; +import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; +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.JobCategory; +import com.stackup.stackup.session.domain.MessageStatus; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +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 org.springframework.test.util.ReflectionTestUtils; + +/** + * 이어하기의 본질은 상태 전이가 아니라 **끊긴 턴 복구**다. + * 중단은 턴 한가운데서 일어나고, 그동안 도착한 콜백은 terminal 가드가 전부 드롭했다. + */ +@ExtendWith(MockitoExtension.class) +class SessionResumeServiceTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock InterviewMessageRepository messageRepository; + @Mock SessionContextRepository contextRepository; + @Mock SessionQuestionPoolRepository poolRepository; + @Mock QuestionsCallbackService questionsCallbackService; + @Mock ApplicationEventPublisher events; + @InjectMocks SessionResumeService service; + + // ── 상태 전이 ───────────────────────────────────────────────────────────── + + @Test + void resume_rejectsSessionThatIsNotInterrupted() { + InterviewSession completed = sessionFixture(10L, SessionStatus.COMPLETED); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(completed)); + + assertThatThrownBy(() -> service.resume(1L, 10L)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); + + verify(sessionRepository, never()).resumeIfInterrupted(anyLong(), any()); + } + + @Test + void resume_rejectsSessionOfAnotherUser() { + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.resume(1L, 10L)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_NOT_FOUND)); + } + + // 중복 요청(더블클릭·재전송) 중 하나만 전이를 차지한다. + @Test + void resume_failsWhenTransitionIsClaimedByAnotherRequest() { + InterviewSession interrupted = sessionFixture(10L, SessionStatus.INTERRUPTED); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(interrupted)); + when(sessionRepository.resumeIfInterrupted(eq(10L), any(Instant.class))).thenReturn(0); + + assertThatThrownBy(() -> service.resume(1L, 10L)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); + } + + // ── 턴 복구 ─────────────────────────────────────────────────────────────── + + // 답할 질문이 그대로 남아 있으면 건드릴 게 없다. + @Test + void resume_leavesAnsweredQuestionAlone() { + InterviewSession session = resumable(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 3, "ACID 를 설명해 주세요."); + ReflectionTestUtils.setField(question, "id", 100L); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(question)); + + SessionResult result = service.resume(1L, 10L); + + assertThat(result.status()).isEqualTo(SessionStatus.IN_PROGRESS); + verify(questionsCallbackService, never()).advanceToNextGeneral(anyLong()); + verify(events, never()).publishEvent(any(SelfIntroAnsweredEvent.class)); + } + + // "(생성 중)" 에서 끊겼다면 그 꼬리질문은 영영 오지 않는다(콜백이 드롭됐다). + // 그대로 두면 재개해도 화면이 생성 중에 멈춘다. + @Test + void resume_failsDanglingPlaceholderAndAdvances() { + InterviewSession session = resumable(10L); + InterviewMessage parent = InterviewMessage.interviewer(session, 3, "부모 질문"); + InterviewMessage placeholder = InterviewMessage.followupPlaceholder(session, 4, parent); + ReflectionTestUtils.setField(placeholder, "id", 101L); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(placeholder)); + + service.resume(1L, 10L); + + assertThat(placeholder.getStatus()).isEqualTo(MessageStatus.FAILED); + assertThat(placeholder.getContent()) + .isEqualTo(InterviewMessage.FOLLOWUP_GENERATION_FAILED_TEXT); + verify(questionsCallbackService).advanceToNextGeneral(10L); + } + + // 답변까지 하고 다음 질문을 못 받은 채 끊긴 경우 — 그냥 재개하면 답할 게 없다. + @Test + void resume_advancesWhenAnswerHasNoNextQuestion() { + InterviewSession session = resumable(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 3, "일반 질문"); + InterviewMessage answer = InterviewMessage.interviewee(session, 4, "제 답변", question, null); + ReflectionTestUtils.setField(answer, "id", 102L); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(answer)); + + service.resume(1L, 10L); + + verify(questionsCallbackService).advanceToNextGeneral(10L); + verify(events, never()).publishEvent(any(SelfIntroAnsweredEvent.class)); + } + + // 자기소개만 답하고 끊겼는데 풀이 아예 없으면, 다음 질문으로 넘길 게 아니라 + // 질문 풀 생성을 다시 요청해야 한다(넘기면 POOL_EXHAUSTED 로 세션이 끝나버린다). + @Test + void resume_reRequestsQuestionPoolWhenSelfIntroAnsweredButPoolMissing() { + InterviewSession session = resumable(10L); + InterviewMessage selfIntro = InterviewMessage.selfIntroduction(session, 1); + InterviewMessage answer = + InterviewMessage.interviewee(session, 2, "안녕하세요, 백엔드 3년차입니다", selfIntro, null); + ReflectionTestUtils.setField(answer, "id", 103L); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(answer)); + when(poolRepository.countBySessionId(10L)).thenReturn(0L); + + service.resume(1L, 10L); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(SelfIntroAnsweredEvent.class); + verify(events).publishEvent(captor.capture()); + assertThat(captor.getValue().sessionId()).isEqualTo(10L); + assertThat(captor.getValue().selfIntroAnswer()).isEqualTo("안녕하세요, 백엔드 3년차입니다"); + verify(questionsCallbackService, never()).advanceToNextGeneral(anyLong()); + } + + // 풀이 이미 있으면(생성은 됐고 다음 질문만 못 받은 것) 다음 질문으로 넘긴다. + @Test + void resume_advancesWhenSelfIntroAnsweredAndPoolExists() { + InterviewSession session = resumable(10L); + InterviewMessage selfIntro = InterviewMessage.selfIntroduction(session, 1); + InterviewMessage answer = InterviewMessage.interviewee(session, 2, "자기소개", selfIntro, null); + ReflectionTestUtils.setField(answer, "id", 104L); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(answer)); + when(poolRepository.countBySessionId(10L)).thenReturn(4L); + + service.resume(1L, 10L); + + verify(questionsCallbackService).advanceToNextGeneral(10L); + verify(events, never()).publishEvent(any(SelfIntroAnsweredEvent.class)); + } + + // ── fixtures ────────────────────────────────────────────────────────────── + + /** 소유권·전이·재조회까지 통과해 복구 단계로 들어가는 세션. */ + private InterviewSession resumable(Long id) { + InterviewSession interrupted = sessionFixture(id, SessionStatus.INTERRUPTED); + InterviewSession afterResume = sessionFixture(id, SessionStatus.IN_PROGRESS); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(id, 1L)) + .thenReturn(Optional.of(interrupted)); + when(sessionRepository.resumeIfInterrupted(eq(id), any(Instant.class))).thenReturn(1); + when(sessionRepository.findById(id)).thenReturn(Optional.of(afterResume)); + lenient().when(contextRepository.findBySession_Id(id)).thenReturn(List.of()); + return afterResume; + } + + private InterviewSession sessionFixture(Long id, SessionStatus status) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "면접", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null + ); + ReflectionTestUtils.setField(s, "id", id); + ReflectionTestUtils.setField(s, "status", status); + return s; + } +} From 1ba796e24d5b930215ff7025122bbf23b16b085f Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 15:10:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20API?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=9E=AC=EC=83=9D=EC=84=B1=20(resume?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/api/generated.ts | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index a145a5d4..b3aca3be 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -511,6 +511,26 @@ export interface paths { patch: operations["startSession"]; trace?: never; }; + "/api/sessions/{sessionId}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * 중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS) + * @description 중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, 질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. 완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다. + */ + patch: operations["resumeSession"]; + trace?: never; + }; "/api/sessions/{sessionId}/interrupt": { parameters: { query?: never; @@ -3045,6 +3065,55 @@ export interface operations { }; }; }; + resumeSession: { + parameters: { + query?: never; + header?: never; + path: { + sessionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 재개됨 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SessionResponse"]; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SessionResponse"]; + }; + }; + /** @description 세션 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SessionResponse"]; + }; + }; + /** @description INTERRUPTED 아님 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SessionResponse"]; + }; + }; + }; + }; interruptSession: { parameters: { query?: never; From 6e7a62fb800cca56135a16d993c922d344385e97 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 15:10:04 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(frontend):=20=EC=A4=91=EB=8B=A8?= =?UTF-8?q?=EB=90=9C=20=EB=A9=B4=EC=A0=91=20=EC=9D=B4=EC=96=B4=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20CTA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종료 패널: 중단 세션에 '이어서 진행하기' 를 primary 로. 하던 대화가 그대로 남아 있으니 새로 만드는 것보다 이어가는 게 먼저다. '같은 설정으로 다시' 는 secondary 로 내렸다 - 히스토리 카드: 중단 세션 CTA 를 '기록 보기 →' 에서 '이어하기 →' 로 - `useResumeSession` — 서버가 끊긴 턴까지 복구하므로(다음 질문 발행 등) 세션과 메시지를 모두 무효화해야 화면이 살아난다 테스트: `SessionEndedPanel.test.tsx` +2(중단 세션의 이어하기 호출, 완료 세션엔 미노출), `SessionCard.test.tsx` CTA 문구 갱신. vitest 113/113 · eslint 0 · tsc · build 통과. --- .../features/history/ui/SessionCard.test.tsx | 6 ++--- .../src/features/history/ui/SessionCard.tsx | 2 +- .../src/features/interview/api/sessionApi.ts | 5 ++++ frontend/src/features/interview/index.ts | 1 + .../interview/model/useResumeSession.ts | 24 +++++++++++++++++++ .../ui/live/SessionEndedPanel.test.tsx | 23 ++++++++++++++++++ .../interview/ui/live/SessionEndedPanel.tsx | 10 +++++++- 7 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/interview/model/useResumeSession.ts diff --git a/frontend/src/features/history/ui/SessionCard.test.tsx b/frontend/src/features/history/ui/SessionCard.test.tsx index 0f370e44..f4ab0224 100644 --- a/frontend/src/features/history/ui/SessionCard.test.tsx +++ b/frontend/src/features/history/ui/SessionCard.test.tsx @@ -30,12 +30,12 @@ describe('SessionCard', () => { expect(screen.getByText('리포트 →')).toBeInTheDocument() }) - // 중단된 면접은 피드백이 없다. 문답 기록으로 갈 경로가 없으면 한 말이 통째로 사라진다. - it('중단 세션은 세션 화면(기록)으로 간다', () => { + // 중단된 면접은 피드백이 없다. 세션 화면에서 기록을 보고 이어서 진행할 수 있다. + it('중단 세션은 세션 화면(이어하기)으로 간다', () => { renderCard({ status: 'INTERRUPTED' }) expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7') - expect(screen.getByText('기록 보기 →')).toBeInTheDocument() + expect(screen.getByText('이어하기 →')).toBeInTheDocument() }) // 진행 중 면접에서 이탈했을 때 돌아갈 경로. diff --git a/frontend/src/features/history/ui/SessionCard.tsx b/frontend/src/features/history/ui/SessionCard.tsx index cb5adce4..bcdb95b1 100644 --- a/frontend/src/features/history/ui/SessionCard.tsx +++ b/frontend/src/features/history/ui/SessionCard.tsx @@ -32,7 +32,7 @@ const JOB: Record = { */ const LINK: Record string; cta: string }> = { COMPLETED: { to: (id) => `/sessions/${id}/feedback`, cta: '리포트 →' }, - INTERRUPTED: { to: (id) => `/sessions/${id}`, cta: '기록 보기 →' }, + INTERRUPTED: { to: (id) => `/sessions/${id}`, cta: '이어하기 →' }, IN_PROGRESS: { to: (id) => `/sessions/${id}`, cta: '이어서 →' }, READY: { to: (id) => `/sessions/${id}`, cta: '시작하기 →' }, } diff --git a/frontend/src/features/interview/api/sessionApi.ts b/frontend/src/features/interview/api/sessionApi.ts index 0d06686f..f2eca0a8 100644 --- a/frontend/src/features/interview/api/sessionApi.ts +++ b/frontend/src/features/interview/api/sessionApi.ts @@ -27,6 +27,11 @@ export async function startSession(id: number): Promise { return (await apiClient.patch(`/api/sessions/${id}/start`)).data } +// 중단된 면접 이어하기. 서버가 상태 전이 + 끊긴 턴 복구까지 한다. +export async function resumeSession(id: number): Promise { + return (await apiClient.patch(`/api/sessions/${id}/resume`)).data +} + export async function endSession(id: number): Promise { return (await apiClient.patch(`/api/sessions/${id}/end`)).data } diff --git a/frontend/src/features/interview/index.ts b/frontend/src/features/interview/index.ts index 30192cb7..86ab6099 100644 --- a/frontend/src/features/interview/index.ts +++ b/frontend/src/features/interview/index.ts @@ -5,6 +5,7 @@ export { InterviewSetupForm } from './ui/setup/InterviewSetupForm' export type { DocOption } from './ui/setup/ContextDocumentPicker' export { useCreateSession } from './model/useCreateSession' export { useRetrySession } from './model/useRetrySession' +export { useResumeSession } from './model/useResumeSession' export { useSession } from './model/useSession' export { useBookmarks, useSetQuestionBookmark, bookmarkKeys } from './model/useBookmarks' export { useLiveInterview } from './model/useLiveInterview' diff --git a/frontend/src/features/interview/model/useResumeSession.ts b/frontend/src/features/interview/model/useResumeSession.ts new file mode 100644 index 00000000..f7e77ac2 --- /dev/null +++ b/frontend/src/features/interview/model/useResumeSession.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from '@/shared/ui' +import { resumeSession } from '../api/sessionApi' +import { sessionKeys } from './useSession' +import { messageKeys } from './useSessionMessages' + +/** + * 중단된 면접 이어하기. 서버가 끊긴 턴까지 복구하므로(다음 질문 발행 등) + * 세션과 메시지를 모두 다시 읽어야 화면이 살아난다. + */ +export function useResumeSession(sessionId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => resumeSession(sessionId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) }) + void queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) }) + void queryClient.invalidateQueries({ queryKey: sessionKeys.all }) + toast.success('면접을 이어서 진행합니다') + }, + onError: () => + toast.error('면접을 이어가지 못했어요. 잠시 후 다시 시도해 주세요.'), + }) +} diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx index d3d621d5..b56521b8 100644 --- a/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx @@ -12,6 +12,10 @@ vi.mock('../../model/useRetrySession', () => ({ return { mutate: retryMutate, isPending: false } }, })) +const resumeMutate = vi.fn() +vi.mock('../../model/useResumeSession', () => ({ + useResumeSession: () => ({ mutate: resumeMutate, isPending: false }), +})) vi.mock('../InterviewTranscript', () => ({ InterviewTranscript: () =>
, })) @@ -19,6 +23,7 @@ vi.mock('../InterviewTranscript', () => ({ beforeEach(() => { retryMutate.mockClear() lastSourceCount.mockClear() + resumeMutate.mockClear() }) function renderPanel(status: 'COMPLETED' | 'INTERRUPTED' | 'CANCELLED') { @@ -80,6 +85,24 @@ describe('SessionEndedPanel', () => { ).not.toBeInTheDocument() }) + // 중단된 면접의 첫 선택지는 새로 만드는 게 아니라 하던 걸 이어가는 것이다. + it('중단 세션은 이어서 진행하기를 제안한다', async () => { + renderPanel('INTERRUPTED') + + await userEvent.click(screen.getByRole('button', { name: '이어서 진행하기' })) + + expect(resumeMutate).toHaveBeenCalled() + }) + + // 완료 세션은 이미 피드백이 나갔다 — 이어갈 대화가 없다. + it('완료 세션에는 이어하기를 제안하지 않는다', () => { + renderPanel('COMPLETED') + + expect( + screen.queryByRole('button', { name: '이어서 진행하기' }), + ).not.toBeInTheDocument() + }) + // 자료 수를 넘겨야 "삭제된 자료 N개 제외" 안내가 가능하다. it('원본 세션의 자료 수를 재도전 훅에 넘긴다', () => { render( diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx index fa10b51c..89b63793 100644 --- a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom' import { Button } from '@/shared/ui/Button' import { Eyebrow } from '@/shared/ui' import type { Session, SessionStatus } from '@/domain/session' +import { useResumeSession } from '../../model/useResumeSession' import { useRetrySession } from '../../model/useRetrySession' import { InterviewTranscript } from '../InterviewTranscript' @@ -27,6 +28,7 @@ export function SessionEndedPanel({ session?: Session }) { const retry = useRetrySession(session?.contextDocumentIds?.length) + const resume = useResumeSession(sessionId) return (
@@ -39,6 +41,12 @@ export function SessionEndedPanel({ {messageByStatus[status] ?? '면접이 종료되었습니다.'}

+ {/* 중단된 면접은 이어서 하는 게 첫 선택지다 — 하던 대화가 그대로 남아 있다. */} + {status === 'INTERRUPTED' && ( + + )} {status === 'COMPLETED' && ( @@ -46,7 +54,7 @@ export function SessionEndedPanel({ )} {/* 중단됐든 끝났든, 같은 조건으로 한 번 더 해보는 게 다음 행동이다. */}