Skip to content

Commit 287d212

Browse files
authored
Merge pull request #180 from Team-StackUp/feature/resume-interrupted-session
feat: 중단된 면접 이어하기 (B-5)
2 parents 84466e9 + 2a57a6c commit 287d212

18 files changed

Lines changed: 627 additions & 8 deletions

File tree

backend/CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,17 @@ docker compose up -d
465465
`findByParentMessage_IdIn` 으로 한 번에 받아 매핑한다(질문마다 조회하면 N+1).
466466
`QuestionBookmarkController` 는 URL 이 `/api/users/me/*` 지만 `UserStatsController` 와 같은 이유로
467467
session 슬라이스에 둔다(user → session 직접 의존 회피).
468+
- **중단 세션 이어하기 본 구현 (B-5)**: `PATCH /api/sessions/{id}/resume` — INTERRUPTED 만 재개
469+
가능(완료·취소는 422, 새로 하려면 `/retry`). `resumeIfInterrupted` 조건부 UPDATE 로 전이를
470+
차지하고 `ended_at` 을 지우며 `resumed_at`(V27)을 찍는다.
471+
- **시간 한도 기준을 `durationAnchor()`(= resumedAt ?? startedAt) 로 바꿨다.** startedAt 기준
472+
그대로면 한참 뒤 재개했을 때 스위퍼가 즉시 다시 중단시킨다. startedAt 은 '처음 시작한 시각'
473+
으로 보존된다. 이어하기를 반복하면 총 시간이 늘어나지만, 연습 도구라 허용하는 트레이드오프.
474+
- **핵심은 전이가 아니라 끊긴 턴 복구다**(`SessionResumeService.recoverTurn`). 중단은 보통 턴
475+
한가운데서 일어나고 그동안 온 콜백은 terminal 가드가 전부 드롭했다. 마지막 메시지로 분기:
476+
정상 질문이면 그대로(답하면 됨) / "(생성 중)" placeholder 면 `failFollowup` + 다음 일반질문 /
477+
자기소개 답변인데 풀이 0건이면 `SelfIntroAnsweredEvent` 재발행(넘기면 POOL_EXHAUSTED 로
478+
세션이 끝나버린다) / 그 외 답변이면 다음 일반질문.
468479
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
469480
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.
470481

backend/openapi.json

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1848,6 +1848,65 @@
18481848
}
18491849
}
18501850
},
1851+
"/api/sessions/{sessionId}/resume" : {
1852+
"patch" : {
1853+
"tags" : [ "Sessions" ],
1854+
"summary" : "중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS)",
1855+
"description" : "중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, 질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. 완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다.",
1856+
"operationId" : "resumeSession",
1857+
"parameters" : [ {
1858+
"name" : "sessionId",
1859+
"in" : "path",
1860+
"required" : true,
1861+
"schema" : {
1862+
"type" : "integer",
1863+
"format" : "int64"
1864+
}
1865+
} ],
1866+
"responses" : {
1867+
"200" : {
1868+
"description" : "재개됨",
1869+
"content" : {
1870+
"*/*" : {
1871+
"schema" : {
1872+
"$ref" : "#/components/schemas/SessionResponse"
1873+
}
1874+
}
1875+
}
1876+
},
1877+
"401" : {
1878+
"description" : "인증 실패",
1879+
"content" : {
1880+
"*/*" : {
1881+
"schema" : {
1882+
"$ref" : "#/components/schemas/SessionResponse"
1883+
}
1884+
}
1885+
}
1886+
},
1887+
"404" : {
1888+
"description" : "세션 없음",
1889+
"content" : {
1890+
"*/*" : {
1891+
"schema" : {
1892+
"$ref" : "#/components/schemas/SessionResponse"
1893+
}
1894+
}
1895+
}
1896+
},
1897+
"422" : {
1898+
"description" : "INTERRUPTED 아님",
1899+
"content" : {
1900+
"*/*" : {
1901+
"schema" : {
1902+
"$ref" : "#/components/schemas/SessionResponse"
1903+
}
1904+
}
1905+
}
1906+
}
1907+
}
1908+
}
1909+
},
18511910
"/api/sessions/{sessionId}/interrupt" : {
18521911
"patch" : {
18531912
"tags" : [ "Sessions" ],
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
}

backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ public void sweep() {
5252
}
5353
}
5454

55+
// 기준 시각은 startedAt 이 아니라 durationAnchor() — 이어하기로 재개했다면 그 시각부터
56+
// 다시 잰다. 아니면 재개하자마자 스위퍼가 즉시 다시 중단시킨다.
5557
private boolean isTimedOut(InterviewSession s, Instant now) {
56-
if (s.getStartedAt() == null || s.getMaxDurationMinutes() == null) {
58+
Instant anchor = s.durationAnchor();
59+
if (anchor == null || s.getMaxDurationMinutes() == null) {
5760
return false;
5861
}
59-
Instant deadline = s.getStartedAt().plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES);
60-
return now.isAfter(deadline);
62+
return now.isAfter(anchor.plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES));
6163
}
6264
}

backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ public class InterviewSession extends BaseSoftDeleteEntity {
110110
@Column(name = "ended_at")
111111
private Instant endedAt;
112112

113+
// 중단 후 이어하기로 재개한 시각. 시간 한도는 이 값 기준으로 다시 잰다(없으면 startedAt).
114+
// startedAt 은 '처음 시작한 시각'으로 보존한다.
115+
@Column(name = "resumed_at")
116+
private Instant resumedAt;
117+
113118
private InterviewSession(User user, String title, String memo, SessionMode mode,
114119
List<JobCategory> jobCategories,
115120
Integer maxQuestions, Integer maxDurationMinutes,
@@ -175,6 +180,11 @@ public void assignTargetRole(String companyName, String jobDescription) {
175180
this.targetJobDescription = jobDescription;
176181
}
177182

183+
// 시간 한도의 기준 시각. 이어하기로 재개했다면 그 자리(sitting)의 시작이 기준이다.
184+
public Instant durationAnchor() {
185+
return resumedAt != null ? resumedAt : startedAt;
186+
}
187+
178188
public void start() {
179189
if (status != SessionStatus.READY) {
180190
throw new IllegalStateException("session is not READY to start (current=" + status + ")");

backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ int finishIfInProgress(@Param("id") Long id,
5858
+ "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.READY")
5959
int startIfReady(@Param("id") Long id, @Param("now") Instant now);
6060

61+
// 원자적 재개 전이: INTERRUPTED 일 때만 IN_PROGRESS 로 되돌린다. endedAt 을 지우고
62+
// resumedAt 을 새로 찍어 시간 한도를 이 자리 기준으로 다시 재게 한다.
63+
// 다른 전이와 같은 조건부 UPDATE 패턴 — 중복 요청 중 하나만 1을 받는다.
64+
@Modifying
65+
@Query("update InterviewSession s set s.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS, "
66+
+ "s.resumedAt = :now, s.endedAt = null "
67+
+ "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.INTERRUPTED")
68+
int resumeIfInterrupted(@Param("id") Long id, @Param("now") Instant now);
69+
6170
// 원자적 취소 전이: READY 일 때만 CANCELLED 로 (동시 start 와의 레이스 차단).
6271
@Modifying
6372
@Query("update InterviewSession s set s.status = com.stackup.stackup.session.domain.SessionStatus.CANCELLED "

backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.stackup.stackup.common.response.PageResponse;
44
import com.stackup.stackup.common.security.UserPrincipal;
5+
import com.stackup.stackup.session.application.SessionResumeService;
56
import com.stackup.stackup.session.application.SessionService;
67
import com.stackup.stackup.session.presentation.dto.SessionCreateRequest;
78
import com.stackup.stackup.session.presentation.dto.SessionResponse;
@@ -36,6 +37,7 @@
3637
public class SessionController {
3738

3839
private final SessionService sessionService;
40+
private final SessionResumeService resumeService;
3941

4042
@Operation(
4143
operationId = "createSession",
@@ -132,6 +134,28 @@ public SessionResponse update(
132134
));
133135
}
134136

137+
@Operation(
138+
operationId = "resumeSession",
139+
summary = "중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS)",
140+
description = "중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 "
141+
+ "끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, "
142+
+ "질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. "
143+
+ "완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다."
144+
)
145+
@ApiResponses({
146+
@ApiResponse(responseCode = "200", description = "재개됨"),
147+
@ApiResponse(responseCode = "401", description = "인증 실패"),
148+
@ApiResponse(responseCode = "404", description = "세션 없음"),
149+
@ApiResponse(responseCode = "422", description = "INTERRUPTED 아님")
150+
})
151+
@PatchMapping("/{sessionId}/resume")
152+
public SessionResponse resume(
153+
@AuthenticationPrincipal UserPrincipal principal,
154+
@PathVariable Long sessionId
155+
) {
156+
return SessionResponse.from(resumeService.resume(principal.userId(), sessionId));
157+
}
158+
135159
@Operation(operationId = "startSession", summary = "세션 시작 (READY→IN_PROGRESS) (US-17)")
136160
@ApiResponses({
137161
@ApiResponse(responseCode = "200", description = "시작됨"),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- B-5 중단 세션 이어하기. INTERRUPTED → IN_PROGRESS 재개를 허용한다.
2+
-- 시간 한도(max_duration_minutes)는 started_at 기준인데, 한참 뒤에 재개하면 스위퍼가
3+
-- 즉시 다시 중단시킨다. 재개 시각을 따로 두고 스위퍼가 COALESCE(resumed_at, started_at)
4+
-- 기준으로 재도록 해, 이어하기마다 그 자리(sitting)의 시간이 새로 시작되게 한다.
5+
-- started_at 은 '처음 시작한 시각'으로 보존된다(히스토리 표시용).
6+
ALTER TABLE interview_sessions ADD COLUMN resumed_at TIMESTAMPTZ;

0 commit comments

Comments
 (0)