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
5 changes: 5 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,11 @@ docker compose up -d
- ArchUnit 룰 적용 (의존 방향 · 순환 차단 · `@Transactional` application 한정 · entity는 domain 패키지)
- 면접 도메인 (US-13~20) 본 구현: 세션 CRUD/start/end/interrupt, generate.questions 발행,
callback.questions(POOL/FOLLOWUP) 수신, 자동 종료
- **세션 시간초과 자동 종료 본 구현**: `@EnableScheduling`(`common/config/SchedulingConfig`) + `SessionTimeoutSweeper`
(`@Scheduled` 기본 5분 주기)가 `maxDurationMinutes` 초과한 IN_PROGRESS 세션을 찾아 `SessionTimeoutService.endTimedOut`
호출 — 답변 있으면 COMPLETED(→`SessionEndedEvent(DURATION_EXCEEDED)`→피드백), 없으면 INTERRUPTED(피드백 없음).
좀비 세션(자기소개 미답변·STT 실패·탭 종료) 방지. 멀티 인스턴스 중복 실행도 IN_PROGRESS 재확인+피드백 멱등으로 안전.
주기: `interview.session.sweep-interval-ms`(기본 300000)·`sweep-initial-delay-ms`(기본 60000).
- **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment`
추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면
`FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.stackup.stackup.common.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

// 스케줄링 활성화. 현재 사용처: 시간 초과 IN_PROGRESS 세션 자동 종료(SessionTimeoutSweeper).
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.stackup.stackup.session.application;

import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
import com.stackup.stackup.common.sse.SseEventType;
import com.stackup.stackup.session.application.event.SessionEndedEvent;
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.SessionStatus;
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;

// 시간 초과된 진행 중 세션을 자동 종료한다. 답변이 하나라도 있으면 COMPLETED(→ SessionEndedEvent → 피드백 생성),
// 없으면(자기소개도 안 한 채 방치) INTERRUPTED 로 정리해 피드백을 만들지 않는다.
@Service
@RequiredArgsConstructor
public class SessionTimeoutService {

private static final Logger log = LoggerFactory.getLogger(SessionTimeoutService.class);
private static final String REASON = "DURATION_EXCEEDED";

private final InterviewSessionRepository sessionRepository;
private final InterviewMessageRepository messageRepository;
private final ApplicationEventPublisher events;

@Transactional
public void endTimedOut(Long sessionId) {
InterviewSession session = sessionRepository.findById(sessionId).orElse(null);
if (session == null || session.isDeleted()
|| session.getStatus() != SessionStatus.IN_PROGRESS) {
return; // 이미 종료됐거나 레이스 — skip
}
boolean hasAnswer =
messageRepository.existsBySession_IdAndRole(sessionId, MessageRole.INTERVIEWEE);
Long userId = session.getUser().getId();
try {
if (hasAnswer) {
session.end(); // COMPLETED → SessionEndedEvent 로 피드백 생성
publishState(session, userId);
events.publishEvent(new SessionEndedEvent(userId, sessionId, REASON));
log.info("session auto-completed (timeout). sessionId={}", sessionId);
} else {
session.interrupt(); // 답변 없음 → 피드백 없이 정리
publishState(session, userId);
log.info("session auto-interrupted (timeout, no answers). sessionId={}", sessionId);
}
} catch (IllegalStateException e) {
log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}",
sessionId, session.getStatus());
}
}

private void publishState(InterviewSession session, Long userId) {
SessionStateNotice notice =
new SessionStateNotice(session.getId(), session.getStatus().name(), REASON);
events.publishEvent(RealtimeNotifyEvent.session(
session.getId(), SseEventType.SESSION_STATE, notice));
events.publishEvent(RealtimeNotifyEvent.user(
userId, SseEventType.SESSION_STATE, notice));
}

public record SessionStateNotice(Long sessionId, String status, String reason) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.stackup.stackup.session.application;

import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.SessionStatus;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

// 주기적으로 시간 초과된 진행 중 세션을 자동 종료한다(maxDurationMinutes 자동종료 — 좀비 세션 방지).
// 종료 한도는 세션별 maxDurationMinutes(startedAt 기준). 진행 세션은 소수라 전체 조회 후 메모리 필터로 충분.
// 멀티 인스턴스에서 중복 실행돼도 endTimedOut 이 IN_PROGRESS 재확인 + 피드백 멱등으로 안전하다.
@Component
@RequiredArgsConstructor
public class SessionTimeoutSweeper {

private static final Logger log = LoggerFactory.getLogger(SessionTimeoutSweeper.class);

private final InterviewSessionRepository sessionRepository;
private final SessionTimeoutService timeoutService;

@Transactional(propagation = Propagation.NOT_SUPPORTED)
@Scheduled(
fixedDelayString = "${interview.session.sweep-interval-ms:300000}",
initialDelayString = "${interview.session.sweep-initial-delay-ms:60000}")
public void sweep() {
Instant now = Instant.now();
List<InterviewSession> inProgress =
sessionRepository.findByStatusAndDeletedFalse(SessionStatus.IN_PROGRESS);
int ended = 0;
for (InterviewSession s : inProgress) {
if (!isTimedOut(s, now)) {
continue;
}
try {
timeoutService.endTimedOut(s.getId()); // 세션마다 독립 트랜잭션
ended++;
} catch (RuntimeException e) {
log.warn("session timeout-end failed. sessionId={}", s.getId(), e);
}
}
if (ended > 0) {
log.info("session sweeper ended {} timed-out session(s) of {} in-progress",
ended, inProgress.size());
}
}

private boolean isTimedOut(InterviewSession s, Instant now) {
if (s.getStartedAt() == null || s.getMaxDurationMinutes() == null) {
return false;
}
Instant deadline = s.getStartedAt().plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES);
return now.isAfter(deadline);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.stackup.stackup.session.application.event;

// 세션 종료(COMPLETED 전이) commit 후 발화. FeedbackRequester 가 수신.
// 사유: USER_REQUEST | MAX_QUESTIONS_REACHED | POOL_EXHAUSTED
// 사유: USER_REQUEST | MAX_QUESTIONS_REACHED | POOL_EXHAUSTED | DURATION_EXCEEDED(자동 종료)
public record SessionEndedEvent(
Long userId,
Long sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public interface InterviewMessageRepository extends JpaRepository<InterviewMessa

long countBySession_Id(Long sessionId);

boolean existsBySession_IdAndRole(Long sessionId, MessageRole role);

Optional<InterviewMessage> findBySession_IdAndIdempotencyKey(Long sessionId, String idempotencyKey);

@Query("select coalesce(max(m.sequenceNumber), 0) from InterviewMessage m where m.session.id = :sessionId")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ List<Long> findRecentSessionIds(@Param("userId") Long userId,

long countByUser_IdAndStatus(Long userId, SessionStatus status);

// 자동 종료 스위퍼용: 진행 중(또는 특정 상태) 세션 전체. 진행 세션은 소수라 메모리 필터로 충분.
List<InterviewSession> findByStatusAndDeletedFalse(SessionStatus status);

List<InterviewSession> findByUser_IdAndStatusOrderByEndedAtDesc(Long userId, SessionStatus status, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.stackup.stackup.session.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.stackup.stackup.session.application.event.SessionEndedEvent;
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.MessageRole;
import com.stackup.stackup.session.domain.SessionMode;
import com.stackup.stackup.session.domain.SessionStatus;
import com.stackup.stackup.user.domain.User;
import java.util.Optional;
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;

@ExtendWith(MockitoExtension.class)
class SessionTimeoutServiceTest {

@Mock InterviewSessionRepository sessionRepository;
@Mock InterviewMessageRepository messageRepository;
@Mock ApplicationEventPublisher events;
@InjectMocks SessionTimeoutService service;

@Test
void endTimedOut_withAnswer_completesAndEmitsEndedEvent() {
InterviewSession session = inProgressFixture(50L);
when(sessionRepository.findById(50L)).thenReturn(Optional.of(session));
when(messageRepository.existsBySession_IdAndRole(50L, MessageRole.INTERVIEWEE)).thenReturn(true);

service.endTimedOut(50L);

assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED);
verify(events).publishEvent(any(SessionEndedEvent.class));
}

@Test
void endTimedOut_withoutAnswer_interruptsAndDoesNotTriggerFeedback() {
InterviewSession session = inProgressFixture(51L);
when(sessionRepository.findById(51L)).thenReturn(Optional.of(session));
when(messageRepository.existsBySession_IdAndRole(51L, MessageRole.INTERVIEWEE)).thenReturn(false);

service.endTimedOut(51L);

assertThat(session.getStatus()).isEqualTo(SessionStatus.INTERRUPTED);
// 답변이 없으면 피드백을 만들지 않는다 — SessionEndedEvent 미발행.
verify(events, never()).publishEvent(any(SessionEndedEvent.class));
}

@Test
void endTimedOut_notInProgress_isNoop() {
InterviewSession session = inProgressFixture(52L);
session.end(); // 이미 COMPLETED
when(sessionRepository.findById(52L)).thenReturn(Optional.of(session));

service.endTimedOut(52L);

verify(events, never()).publishEvent(any());
verify(messageRepository, never()).existsBySession_IdAndRole(any(), any());
}

private InterviewSession inProgressFixture(Long id) {
User user = User.createGithubUser(1L, "u", null, null, "t");
ReflectionTestUtils.setField(user, "id", 1L);
InterviewSession s = InterviewSession.create(
user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null);
ReflectionTestUtils.setField(s, "id", id);
s.start();
return s;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.stackup.stackup.session.application;

import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

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.SessionMode;
import com.stackup.stackup.session.domain.SessionStatus;
import com.stackup.stackup.user.domain.User;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
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.test.util.ReflectionTestUtils;

@ExtendWith(MockitoExtension.class)
class SessionTimeoutSweeperTest {

@Mock InterviewSessionRepository sessionRepository;
@Mock SessionTimeoutService timeoutService;
@InjectMocks SessionTimeoutSweeper sweeper;

@Test
void sweep_endsOnlyTimedOutSessions() {
// maxDurationMinutes=30. started 2시간 전 → 초과. started 방금 → 미초과.
InterviewSession timedOut = sessionStartedMinutesAgo(60L, 120);
InterviewSession fresh = sessionStartedMinutesAgo(61L, 1);
when(sessionRepository.findByStatusAndDeletedFalse(SessionStatus.IN_PROGRESS))
.thenReturn(List.of(timedOut, fresh));

sweeper.sweep();

verify(timeoutService).endTimedOut(60L);
verify(timeoutService, never()).endTimedOut(61L);
}

private InterviewSession sessionStartedMinutesAgo(Long id, int minutesAgo) {
User user = User.createGithubUser(1L, "u", null, null, "t");
ReflectionTestUtils.setField(user, "id", 1L);
InterviewSession s = InterviewSession.create(
user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null);
ReflectionTestUtils.setField(s, "id", id);
s.start();
ReflectionTestUtils.setField(s, "startedAt", Instant.now().minus(minutesAgo, ChronoUnit.MINUTES));
return s;
}
}