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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 레코드 또는 인메모리로.

Expand Down
59 changes: 59 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" ],
Expand Down
Original file line number Diff line number Diff line change
@@ -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 확장).
*
* <p>상태를 되돌리는 것만으로는 부족하다. 중단은 보통 <b>턴 한가운데</b>에서 일어나고,
* 그동안 도착한 콜백은 terminal 가드가 전부 드롭했다. 그대로 재개하면 사용자는 답할 질문이
* 없거나 "(생성 중)" 에 멈춰 있는 화면을 본다. 그래서 재개는 두 단계다:
* <b>원자적 상태 전이 + 끊긴 턴 복구</b>.
*/
@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));
}

/**
* 끊긴 턴을 이어붙인다. 마지막 메시지가 무엇이냐로 갈린다.
*
* <ul>
* <li>정상 질문 → 할 일 없음. 사용자가 그 질문에 답하면 된다.
* <li>생성 중 placeholder → 그 꼬리질문은 영영 오지 않는다(콜백이 드롭됐다).
* 실패로 확정하고 다음 일반질문으로 넘긴다.
* <li>자기소개 답변인데 질문 풀이 없음 → 풀 생성 요청이 유실된 것. 다시 요청한다.
* <li>그 외 답변 → 다음 질문이 오지 않은 것. 다음 일반질문으로 넘긴다.
* </ul>
*/
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<Long> contextDocumentIds(Long sessionId) {
return contextRepository.findBySession_Id(sessionId).stream()
.map(c -> c.getDocument().getId())
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<JobCategory> jobCategories,
Integer maxQuestions, Integer maxDurationMinutes,
Expand Down Expand Up @@ -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 + ")");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,6 +37,7 @@
public class SessionController {

private final SessionService sessionService;
private final SessionResumeService resumeService;

@Operation(
operationId = "createSession",
Expand Down Expand Up @@ -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 = "시작됨"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading