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
6 changes: 6 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@ docker compose up -d
IP 로 해석되는 공개 도메인도 막힌다). 거부 응답에 내부 주소는 노출하지 않는다. DNS 해석기는 주입
가능(테스트가 네트워크 미의존). 여긴 첫 관문이고 실질 방어선은 AI 쪽 `url_guard.py` 다.
- 같은 URL 재등록은 409(`RESUME_URL_DUPLICATE`) — 임베딩 중복으로 질문이 쏠리는 것 방지.
- **같은 설정으로 다시 면접 본 구현**: `POST /api/sessions/{id}/retry` → `SessionService.retry` 가
원본 세션의 설정(모드·직군·질문 수·JD 등)을 복사해 `create` 를 호출한다. **연결 자료는 지금도
살아있고 ANALYZED 인 것만** 다시 잇는다 — `linkContexts` 는 삭제된 문서에 `DOC_NOT_FOUND`(404),
분석 미완료에 `DOC_NOT_ANALYZED` 를 던지므로, 원본 설정을 그대로 재전송하면 그 사이 자료 하나
지운 사용자는 재도전 자체가 막힌다. 빠진 자료는 응답 `contextDocumentIds` 를 원본과 비교해
프론트가 안내한다.
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.

Expand Down
49 changes: 49 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,55 @@
}
}
},
"/api/sessions/{sessionId}/retry" : {
"post" : {
"tags" : [ "Sessions" ],
"summary" : "같은 설정으로 다시 면접 (US-13 재도전)",
"description" : "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다.",
"operationId" : "retrySession",
"parameters" : [ {
"name" : "sessionId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"responses" : {
"201" : {
"description" : "새 세션 생성 완료",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/SessionResponse"
}
}
}
},
"401" : {
"description" : "인증 실패",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/SessionResponse"
}
}
}
},
"404" : {
"description" : "원본 세션 없음",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/SessionResponse"
}
}
}
}
}
}
},
"/api/sessions/{sessionId}/messages" : {
"get" : {
"tags" : [ "Session Messages" ],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,43 @@ public SessionResult create(Long userId, SessionCreateCommand command) {
return SessionResult.of(session, linkedIds);
}

/**
* 같은 설정으로 새 면접을 만든다(US-13 재도전).
*
* <p>세션 설정을 그대로 복사하되 **자료(context)는 지금도 살아있는 것만** 잇는다. 원본 설정을
* 그대로 재전송하면 그 사이 삭제된 이력서 하나 때문에 {@code DOC_NOT_FOUND}(404)로 전체가
* 막힌다 — 사용자 입장에선 "다시 하기"가 이유 없이 실패하는 것으로 보인다. 빠진 자료는
* 응답의 {@code contextDocumentIds} 로 드러나므로 호출자가 원본과 비교해 안내할 수 있다.
*/
@Transactional
public SessionResult retry(Long userId, Long sourceSessionId) {
InterviewSession source = loadOwned(userId, sourceSessionId);
List<Long> reusableDocumentIds = contextDocumentIds(sourceSessionId).stream()
.filter(id -> isReusableContext(id, userId))
.toList();

return create(userId, new SessionCreateCommand(
source.getTitle(),
source.getMemo(),
source.getMode(),
new ArrayList<>(source.getJobCategories()),
source.getMaxQuestions(),
source.getMaxDurationMinutes(),
source.getGeneralQuestionCount(),
source.getMaxFollowupsPerQuestion(),
reusableDocumentIds,
source.getTargetCompanyName(),
source.getTargetJobDescription()
));
}

// 삭제됐거나 아직 분석이 끝나지 않은 자료는 조용히 제외한다(linkContexts 는 둘 다 예외를 던진다).
private boolean isReusableContext(Long documentId, Long userId) {
return documentRepository.findActiveByIdAndOwner(documentId, userId)
.filter(doc -> doc.getAnalysisStatus() == AnalysisStatus.ANALYZED)
.isPresent();
}

public Page<SessionResult> listPaged(Long userId, Pageable pageable) {
loadUser(userId);
return sessionRepository.findByUser_IdAndDeletedFalse(userId, pageable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ public SessionResponse get(
return SessionResponse.from(sessionService.get(principal.userId(), sessionId));
}

@Operation(
operationId = "retrySession",
summary = "같은 설정으로 다시 면접 (US-13 재도전)",
description = "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. "
+ "연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 "
+ "재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 "
+ "무엇이 빠졌는지 알 수 있다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "새 세션 생성 완료"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "404", description = "원본 세션 없음")
})
@PostMapping("/{sessionId}/retry")
@ResponseStatus(HttpStatus.CREATED)
public SessionResponse retry(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Long sessionId
) {
return SessionResponse.from(sessionService.retry(principal.userId(), sessionId));
}

@Operation(operationId = "updateSessionMeta", summary = "세션 제목/메모 수정")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "갱신 완료"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,87 @@ void create_jobTailoredStoresCompanyAndJd() {
assertThat(result.title()).isEqualTo("토스 백엔드 직무 맞춤 면접");
}

// ── retry (같은 설정으로 다시) ─────────────────────────────────────────────

@Test
void retry_copiesSettingsIntoNewSession() {
User user = userFixture(1L);
InterviewSession source = InterviewSession.create(
user, "백엔드 모의면접", "메모", SessionMode.JOB_TAILORED,
List.of(JobCategory.BACKEND, JobCategory.INFRA), 8, 45, 4, 3
);
ReflectionTestUtils.setField(source, "id", 50L);
source.assignTargetRole("스택업", "JD 본문");

when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.of(source));
when(contextRepository.findBySession_Id(50L)).thenReturn(List.of());
when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user));
when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> {
InterviewSession s = inv.getArgument(0);
ReflectionTestUtils.setField(s, "id", 101L);
return s;
});

SessionResult result = service.retry(1L, 50L);

assertThat(result.id()).isEqualTo(101L);
assertThat(result.title()).isEqualTo("백엔드 모의면접");
assertThat(result.memo()).isEqualTo("메모");
assertThat(result.mode()).isEqualTo(SessionMode.JOB_TAILORED);
assertThat(result.jobCategories())
.containsExactly(JobCategory.BACKEND, JobCategory.INFRA);
assertThat(result.maxQuestions()).isEqualTo(8);
assertThat(result.maxDurationMinutes()).isEqualTo(45);
assertThat(result.generalQuestionCount()).isEqualTo(4);
assertThat(result.maxFollowupsPerQuestion()).isEqualTo(3);
// JOB_TAILORED 의 JD 가 빠지면 새 세션이 SESSION_JD_REQUIRED 로 막힌다.
assertThat(result.targetCompanyName()).isEqualTo("스택업");
assertThat(result.targetJobDescription()).isEqualTo("JD 본문");
assertThat(result.status()).isEqualTo(SessionStatus.READY);
verify(events).publishEvent(any(SessionCreatedEvent.class));
}

// 원본 설정을 그대로 재전송하면 그 사이 삭제된 자료 하나 때문에 404 로 전체가 막힌다.
@Test
void retry_skipsDeletedOrUnanalyzedContextDocuments() {
User user = userFixture(1L);
InterviewSession source = sessionFixture(50L);
// mock 생성·스터빙은 when(...) 바깥에서 먼저 끝낸다(중첩 스터빙 금지).
AnalyzedDocument alive = analyzedDocFixture(7L, AnalysisStatus.ANALYZED);
AnalyzedDocument deleted = analyzedDocFixture(8L, AnalysisStatus.ANALYZED);
AnalyzedDocument reanalyzing = analyzedDocFixture(9L, AnalysisStatus.PROCESSING);
List<SessionContext> contexts = List.of(
contextFixture(source, alive),
contextFixture(source, deleted), // 그 사이 삭제됨
contextFixture(source, reanalyzing) // 재분석 중
);

when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.of(source));
when(contextRepository.findBySession_Id(50L)).thenReturn(contexts);
when(documentRepository.findActiveByIdAndOwner(7L, 1L)).thenReturn(Optional.of(alive));
when(documentRepository.findActiveByIdAndOwner(8L, 1L)).thenReturn(Optional.empty());
when(documentRepository.findActiveByIdAndOwner(9L, 1L)).thenReturn(Optional.of(reanalyzing));
when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user));
when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> {
InterviewSession s = inv.getArgument(0);
ReflectionTestUtils.setField(s, "id", 101L);
return s;
});

SessionResult result = service.retry(1L, 50L);

// 살아있는 7L 만 다시 연결 — 나머지는 조용히 제외한다(호출자가 원본과 비교해 안내).
assertThat(result.contextDocumentIds()).containsExactly(7L);
}

@Test
void retry_rejectsSessionOfAnotherUser() {
when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.empty());

assertThatThrownBy(() -> service.retry(1L, 50L))
.isInstanceOf(DomainException.class);
}

@Test
void start_transitionsReadyToInProgress() {
InterviewSession session = sessionFixture(50L);
Expand Down Expand Up @@ -290,6 +371,10 @@ private InterviewSession sessionFixture(Long id) {
return s;
}

private SessionContext contextFixture(InterviewSession session, AnalyzedDocument doc) {
return SessionContext.link(session, doc);
}

private AnalyzedDocument analyzedDocFixture(Long id, AnalysisStatus status) {
AnalyzedDocument doc = mock(AnalyzedDocument.class);
org.mockito.Mockito.lenient().when(doc.getId()).thenReturn(id);
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/features/interview/api/sessionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export async function getSession(id: number): Promise<S['SessionResponse']> {
return (await apiClient.get<S['SessionResponse']>(`/api/sessions/${id}`)).data
}

// 같은 설정으로 새 세션 생성. 삭제된 자료는 서버가 알아서 빼고 잇는다(응답의 contextDocumentIds).
export async function retrySession(id: number): Promise<S['SessionResponse']> {
return (await apiClient.post<S['SessionResponse']>(`/api/sessions/${id}/retry`)).data
}

export async function startSession(id: number): Promise<S['SessionResponse']> {
return (await apiClient.patch<S['SessionResponse']>(`/api/sessions/${id}/start`)).data
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/features/interview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ export { InterviewTranscript } from './ui/InterviewTranscript'
export { InterviewSetupForm } from './ui/setup/InterviewSetupForm'
export type { DocOption } from './ui/setup/ContextDocumentPicker'
export { useCreateSession } from './model/useCreateSession'
export { useRetrySession } from './model/useRetrySession'
export { useSession } from './model/useSession'
export { useLiveInterview } from './model/useLiveInterview'
33 changes: 33 additions & 0 deletions frontend/src/features/interview/model/useRetrySession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from '@/shared/ui'
import { retrySession } from '../api/sessionApi'
import { sessionKeys } from './useSession'

/**
* 같은 설정으로 다시 면접. 서버가 설정을 복사하고, 그 사이 삭제·재분석 중인 자료는 빼고 잇는다.
*
* @param sourceContextCount 원본 세션이 연결하고 있던 자료 수. 새 세션에 붙은 수와 다르면
* 조용히 빠진 것이므로 사용자에게 알린다 — 모르고 시작하면 질문 근거가 달라진 걸 알 수 없다.
*/
export function useRetrySession(sourceContextCount?: number) {
const navigate = useNavigate()
const queryClient = useQueryClient()

return useMutation({
mutationFn: retrySession,
onSuccess: (session) => {
void queryClient.invalidateQueries({ queryKey: sessionKeys.all })
const linked = session.contextDocumentIds?.length ?? 0
if (sourceContextCount != null && linked < sourceContextCount) {
toast.info(
`삭제된 자료 ${sourceContextCount - linked}개는 제외하고 시작합니다.`,
)
}
navigate(`/sessions/${session.id}`)
},
onError: () => {
toast.error('면접을 다시 만들지 못했어요. 잠시 후 다시 시도해 주세요.')
},
})
}
8 changes: 7 additions & 1 deletion frontend/src/features/interview/ui/live/LiveInterview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ export function LiveInterview({ sessionId }: { sessionId: number }) {
return <InterviewLobby sessionId={sessionId} session={session} />
}
if (status !== 'IN_PROGRESS') {
return <SessionEndedPanel status={status ?? 'COMPLETED'} sessionId={sessionId} />
return (
<SessionEndedPanel
status={status ?? 'COMPLETED'}
sessionId={sessionId}
session={session}
/>
)
}
// 면접은 시작됐지만 첫 질문이 아직 안 왔으면 스테이지 진입 전 대기 화면을 보여준다.
if (!firstQuestionReady) {
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { SessionEndedPanel } from './SessionEndedPanel'

const retryMutate = vi.fn()
const lastSourceCount = vi.fn()
vi.mock('../../model/useRetrySession', () => ({
useRetrySession: (count?: number) => {
lastSourceCount(count)
return { mutate: retryMutate, isPending: false }
},
}))
vi.mock('../InterviewTranscript', () => ({
InterviewTranscript: () => <div data-testid="transcript" />,
}))

beforeEach(() => {
retryMutate.mockClear()
lastSourceCount.mockClear()
})

function renderPanel(status: 'COMPLETED' | 'INTERRUPTED' | 'CANCELLED') {
return render(
<MemoryRouter>
<SessionEndedPanel status={status} sessionId={7} />
</MemoryRouter>,
)
}

describe('SessionEndedPanel', () => {
it('완료 세션은 피드백 링크와 기록을 함께 보여준다', () => {
renderPanel('COMPLETED')

expect(screen.getByRole('link', { name: '피드백 보기' })).toHaveAttribute(
'href',
'/sessions/7/feedback',
)
expect(screen.getByTestId('transcript')).toBeInTheDocument()
})

// 중단된 면접은 피드백이 없다 — 문답 기록이 유일한 결과물이다.
it('중단 세션은 피드백 링크 없이 기록을 보여준다', () => {
renderPanel('INTERRUPTED')

expect(screen.queryByRole('link', { name: '피드백 보기' })).not.toBeInTheDocument()
expect(screen.getByTestId('transcript')).toBeInTheDocument()
})

// 시작 전 취소라 보여줄 문답이 없다.
it('취소 세션은 기록을 보여주지 않는다', () => {
renderPanel('CANCELLED')

expect(screen.queryByTestId('transcript')).not.toBeInTheDocument()
})

it('같은 설정으로 다시 누르면 원본 세션 id 로 재도전한다', async () => {
renderPanel('INTERRUPTED')

await userEvent.click(screen.getByRole('button', { name: '같은 설정으로 다시' }))

expect(retryMutate).toHaveBeenCalledWith(7)
})

// 자료 수를 넘겨야 "삭제된 자료 N개 제외" 안내가 가능하다.
it('원본 세션의 자료 수를 재도전 훅에 넘긴다', () => {
render(
<MemoryRouter>
<SessionEndedPanel
status="COMPLETED"
sessionId={7}
session={{ id: 7, contextDocumentIds: [1, 2, 3] }}
/>
</MemoryRouter>,
)

expect(lastSourceCount).toHaveBeenCalledWith(3)
})
})
Loading
Loading