Skip to content

Commit bddce87

Browse files
authored
Merge pull request #33 from Team-StackUp/feature/us-15-session-message-flow
feat(session): 답변/꼬리질문/종료 + 조회 백엔드 작업
2 parents 10f4728 + 8185b74 commit bddce87

20 files changed

Lines changed: 1427 additions & 30 deletions

backend/CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ com.stackup.stackup.{domain}/
8787
| `github` | GitHub API 연동, 레포 목록/등록/메타 동기화 | US-07, US-08 |
8888
| `resume` | 이력서 업로드(S3)·메타 저장·목록·삭제 | US-05, US-06 |
8989
| `document` | 분석 문서(이력서/레포 공통) 메타 + S3 경로 | US-09~12 |
90-
| `session` | 면접 세션·메시지·피드백 (가장 큰 도메인) | US-13~20, US-24~27 |
90+
| `session` | 면접 세션·메시지·콜백. Sprint 2 에서 application/presentation/infrastructure 도입 (create/submitAnswer/end + callback.questions FIRST/FOLLOWUP/END + SSE push). 피드백은 Sprint 3. | US-13~20 |
9191
| `log.activity` | 사용자 행동 로그 | US-31 |
9292
| `log.ai` | AI 요청/응답 로깅 | US-30 |
9393
| `common` | BaseEntity, 글로벌 예외 핸들러, util ||
@@ -359,5 +359,10 @@ docker compose up -d
359359
- Flyway 미도입 → 첫 entity 작성 PR에서 도입
360360
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
361361
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.
362+
- (2026-05) session 도메인 Phase A·B·C·D·E·F·G·H 완료 — 도메인 메서드 + 메시지 DTO +
363+
SessionService.create/submitAnswer/end + SessionController + callback.questions
364+
(FIRST/FOLLOWUP/END) 컨슈머 + SSE SESSION_MESSAGE/STATE push + 답변 멱등 (Idempotency-Key).
365+
풀 미사용, 매 턴 LLM 호출. DB 마이그레이션 없음. golden path Testcontainer IT 추가
366+
(Docker 필요).
362367

363368
각 도입 시 본 문서 §1, 관련 도메인 `CLAUDE.md` 갱신.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# `session` — 면접 세션 도메인 가이드
2+
3+
> 상위 컨텍스트: [`/backend/CLAUDE.md`](../../../../../CLAUDE.md), [`/backend/src/main/java/com/stackup/stackup/CLAUDE.md`](../CLAUDE.md)
4+
5+
---
6+
7+
## 1. Aggregate
8+
9+
`InterviewSession` (root). 연관:
10+
- `InterviewMessage` (질문/답변 시퀀스 — `(session_id, sequence_number) UNIQUE`)
11+
- `SessionContext` (세션 ↔ `AnalyzedDocument` N:M)
12+
- `MessageVoiceAnalysis` (Phase 2 — 음성)
13+
- `SessionFeedback` (Sprint 3 — 종합 평가)
14+
15+
`InterviewMessage`, `SessionContext` 등 자식 aggregate 는 Repository 가 별도이며 service 가 명시적으로 INSERT/조회.
16+
17+
## 2. 상태 전이 (`SessionStatus`)
18+
19+
```
20+
create()
21+
22+
READY ──markInProgress()──> IN_PROGRESS ──end()──> COMPLETED
23+
│ │
24+
└──cancel()──> CANCELLED └──cancel()──> CANCELLED
25+
26+
└──(network 등)──> INTERRUPTED *(현재 코드 진입 없음)*
27+
```
28+
29+
전이 가드는 `InterviewSession` 도메인 메서드 내부 `IllegalStateException`. 서비스 계층에서는 `SessionInvalidStateException`(`SESSION_INVALID_STATE` → HTTP 422) 으로 사전 차단.
30+
31+
## 3. API endpoints (`/api/sessions`)
32+
33+
| Method | Path | 설명 |
34+
|--------|------|------|
35+
| POST | `/` | 세션 생성 (READY) + `generate.questions` 발행 |
36+
| POST | `/{id}/messages` | 답변 제출 (`Idempotency-Key` 헤더). `generate.followup` 발행 |
37+
| POST | `/{id}/end` | 세션 종료 / 취소 |
38+
| GET | `/{id}` | 단건 조회 |
39+
| GET | `/` | 페이지 목록 (생성일 DESC) |
40+
| GET | `/{id}/messages` | 메시지 시퀀스 ASC |
41+
42+
## 4. 메시지
43+
44+
발행 (`session.application.SessionService`):
45+
- `generate.questions` — 세션 생성 commit 후 AFTER_COMMIT 이벤트 (첫 질문 요청)
46+
- `generate.followup` — 답변 commit 후 AFTER_COMMIT (꼬리질문 요청)
47+
48+
소비 (`session.infrastructure.SessionCallbackHandler``session.application.SessionCallbackService`):
49+
- `core.callback.questions` 큐 — `kind=FIRST/FOLLOWUP/END` 분기
50+
- `FIRST``markInProgress` + 첫 INTERVIEWER 메시지 INSERT + SSE
51+
- `FOLLOWUP` → INTERVIEWER 메시지 INSERT + max 도달 시 자동 종료 + SSE
52+
- `END` → AI 가 조기 종료 신호. `session.end()` + SSE
53+
- 멱등: `processed_messages` (`messageId` PK)
54+
55+
## 5. 답변 멱등 정책
56+
57+
`POST /messages``Idempotency-Key` 헤더 → `processed_messages` 의 PK `session.answer:{uuid}` 로 24h 캐시. 중복 호출 시 가장 최근 INTERVIEWEE 메시지를 그대로 반환 (멱등 충돌 시 시퀀스 중복 방지).
58+
59+
## 6. SSE 이벤트
60+
61+
- `SESSION_MESSAGE` — 새 INTERVIEWER 메시지 도착 시 (FIRST/FOLLOWUP)
62+
- `SESSION_STATE` — 상태 전이 또는 카운트 변화 시
63+
- `ERROR` — 콜백 페이로드 비정상 (예: 빈 question)
64+
65+
전송 경로: Core 인메모리 (`SseEventPublisher.publishToSession`). RealTime 서버 미사용 (Phase 1).
66+
67+
## 7. 안티패턴
68+
69+
- 컨트롤러에서 `@Transactional` (ArchUnit 차단)
70+
- 엔티티에 `@Setter` (ArchUnit 차단)
71+
- Service 에 클래스 레벨 `@Transactional` + `@TransactionalEventListener` 혼용 (Spring 7 제약 — `AnalysisRequestService` 패턴 참조)
72+
- `Map.of(...)` 에 null 값 (NPE)

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

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.stackup.stackup.session.domain.InterviewMessageRepository;
1212
import com.stackup.stackup.session.domain.InterviewSession;
1313
import com.stackup.stackup.session.domain.InterviewSessionRepository;
14+
import com.stackup.stackup.session.domain.SessionStatus;
1415
import java.util.Map;
1516
import lombok.RequiredArgsConstructor;
1617
import org.slf4j.Logger;
@@ -63,6 +64,11 @@ private void applyFirstQuestion(QuestionsCallbackPayload p) {
6364
log.warn("session not found. id={}", p.sessionId());
6465
return;
6566
}
67+
if (session.getStatus() != SessionStatus.READY) {
68+
log.warn("session not READY, drop FIRST. id={}, status={}",
69+
session.getId(), session.getStatus());
70+
return;
71+
}
6672
if (p.question() == null || p.question().isBlank()) {
6773
log.warn("first question is blank. sessionId={}", p.sessionId());
6874
sse.publishToSession(session.getId(), SseEventType.ERROR,
@@ -86,13 +92,73 @@ private void applyFirstQuestion(QuestionsCallbackPayload p) {
8692
}
8793

8894
private void applyFollowup(QuestionsCallbackPayload p) {
89-
// Task G1 에서 구현
90-
throw new UnsupportedOperationException("followup is implemented in Task G1");
95+
InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(p.sessionId()).orElse(null);
96+
if (session == null) {
97+
log.warn("session not found. id={}", p.sessionId());
98+
return;
99+
}
100+
if (session.getStatus() != SessionStatus.IN_PROGRESS) {
101+
log.warn("session not in progress, drop followup. id={}, status={}",
102+
session.getId(), session.getStatus());
103+
return;
104+
}
105+
if (p.parentMessageId() == null) {
106+
log.warn("followup missing parentMessageId. sessionId={}", session.getId());
107+
return;
108+
}
109+
if (p.question() == null || p.question().isBlank()) {
110+
log.warn("followup question blank. sessionId={}", session.getId());
111+
return;
112+
}
113+
114+
InterviewMessage parent = messageRepo.findById(p.parentMessageId()).orElse(null);
115+
if (parent == null) {
116+
log.warn("followup parent not found. id={}", p.parentMessageId());
117+
return;
118+
}
119+
if (!parent.getSession().getId().equals(session.getId())) {
120+
log.warn("followup parent belongs to different session. parentId={}, parent.sessionId={}, expected.sessionId={}",
121+
parent.getId(), parent.getSession().getId(), session.getId());
122+
return;
123+
}
124+
125+
int nextSeq = messageRepo.findMaxSequenceBySessionId(session.getId()) + 1;
126+
InterviewMessage q = messageRepo.save(
127+
InterviewMessage.interviewer(session, nextSeq, p.question(), parent));
128+
session.incrementQuestionCount();
129+
130+
sse.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE,
131+
MessageResult.from(q));
132+
133+
if (session.isMaxReached()) {
134+
session.end();
135+
sse.publishToSession(session.getId(), SseEventType.SESSION_STATE,
136+
Map.of("sessionId", session.getId(), "state", session.getStatus().name(),
137+
"totalQuestionCount", session.getTotalQuestionCount(),
138+
"endedAt", session.getEndedAt()));
139+
} else {
140+
sse.publishToSession(session.getId(), SseEventType.SESSION_STATE,
141+
Map.of("sessionId", session.getId(), "state", session.getStatus().name(),
142+
"totalQuestionCount", session.getTotalQuestionCount()));
143+
}
91144
}
92145

93146
private void applyEnd(QuestionsCallbackPayload p) {
94-
// Task G1 에서 구현
95-
throw new UnsupportedOperationException("end is implemented in Task G1");
147+
InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(p.sessionId()).orElse(null);
148+
if (session == null) {
149+
log.warn("session not found. id={}", p.sessionId());
150+
return;
151+
}
152+
if (session.getStatus() != SessionStatus.IN_PROGRESS) {
153+
log.warn("session not in progress, drop END. id={}, status={}",
154+
session.getId(), session.getStatus());
155+
return;
156+
}
157+
session.end();
158+
sse.publishToSession(session.getId(), SseEventType.SESSION_STATE,
159+
Map.of("sessionId", session.getId(), "state", session.getStatus().name(),
160+
"totalQuestionCount", session.getTotalQuestionCount(),
161+
"endedAt", session.getEndedAt()));
96162
}
97163

98164
private boolean isProcessed(String messageId) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,51 @@
11
package com.stackup.stackup.session.application;
22

3+
import com.stackup.stackup.session.application.dto.MessageResult;
4+
import com.stackup.stackup.session.application.dto.SessionResult;
5+
import com.stackup.stackup.session.application.exception.SessionForbiddenException;
6+
import com.stackup.stackup.session.application.exception.SessionNotFoundException;
7+
import com.stackup.stackup.session.domain.InterviewMessage;
38
import com.stackup.stackup.session.domain.InterviewMessageRepository;
9+
import com.stackup.stackup.session.domain.InterviewSession;
410
import com.stackup.stackup.session.domain.InterviewSessionRepository;
511
import lombok.RequiredArgsConstructor;
12+
import org.springframework.data.domain.Page;
13+
import org.springframework.data.domain.Pageable;
614
import org.springframework.stereotype.Service;
715
import org.springframework.transaction.annotation.Transactional;
816

17+
import java.util.List;
18+
919
@Service
1020
@RequiredArgsConstructor
1121
@Transactional(readOnly = true)
1222
public class SessionQueryService {
23+
1324
private final InterviewSessionRepository sessionRepo;
1425
private final InterviewMessageRepository messageRepo;
26+
27+
public SessionResult get(Long sessionId, Long userId) {
28+
InterviewSession s = sessionRepo.findByIdAndIsDeletedFalse(sessionId)
29+
.orElseThrow(() -> new SessionNotFoundException(sessionId));
30+
if (!s.getUser().getId().equals(userId)) {
31+
throw new SessionForbiddenException(sessionId);
32+
}
33+
return SessionResult.from(s);
34+
}
35+
36+
public Page<SessionResult> list(Long userId, Pageable pageable) {
37+
return sessionRepo
38+
.findByUser_IdAndIsDeletedFalseOrderByCreatedAtDesc(userId, pageable)
39+
.map(SessionResult::from);
40+
}
41+
42+
public List<MessageResult> getMessages(Long sessionId, Long userId) {
43+
InterviewSession s = sessionRepo.findByIdAndIsDeletedFalse(sessionId)
44+
.orElseThrow(() -> new SessionNotFoundException(sessionId));
45+
if (!s.getUser().getId().equals(userId)) {
46+
throw new SessionForbiddenException(sessionId);
47+
}
48+
return messageRepo.findBySession_IdOrderBySequenceNumberAsc(sessionId)
49+
.stream().map(MessageResult::from).toList();
50+
}
1551
}

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,51 @@
33
import com.stackup.stackup.common.config.properties.RabbitMqProperties;
44
import com.stackup.stackup.common.messaging.MessageContext;
55
import com.stackup.stackup.common.messaging.RabbitMessagePublisher;
6+
import com.stackup.stackup.common.messaging.domain.ProcessedMessage;
67
import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository;
78
import com.stackup.stackup.common.sse.SseEventPublisher;
9+
import com.stackup.stackup.common.sse.SseEventType;
810
import com.stackup.stackup.document.domain.AnalyzedDocument;
911
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
12+
import com.stackup.stackup.session.application.dto.GenerateFollowupPayload;
1013
import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload;
14+
import com.stackup.stackup.session.application.dto.MessageResult;
15+
import com.stackup.stackup.session.application.dto.MessageSubmitCommand;
1116
import com.stackup.stackup.session.application.dto.SessionCreateCommand;
1217
import com.stackup.stackup.session.application.dto.SessionResult;
18+
import com.stackup.stackup.session.application.event.AnswerSubmittedEvent;
1319
import com.stackup.stackup.session.application.event.SessionCreatedEvent;
1420
import com.stackup.stackup.session.application.exception.SessionForbiddenException;
21+
import com.stackup.stackup.session.application.exception.SessionInvalidStateException;
22+
import com.stackup.stackup.session.application.exception.SessionNotFoundException;
1523
import com.stackup.stackup.session.domain.InterviewMessage;
1624
import com.stackup.stackup.session.domain.InterviewMessageRepository;
1725
import com.stackup.stackup.session.domain.InterviewSession;
1826
import com.stackup.stackup.session.domain.InterviewSessionRepository;
27+
import com.stackup.stackup.session.domain.MessageRole;
1928
import com.stackup.stackup.session.domain.SessionContext;
2029
import com.stackup.stackup.session.domain.SessionContextRepository;
30+
import com.stackup.stackup.session.domain.SessionStatus;
2131
import com.stackup.stackup.user.domain.User;
2232
import com.stackup.stackup.user.domain.UserRepository;
2333
import lombok.RequiredArgsConstructor;
2434
import org.springframework.context.ApplicationEventPublisher;
35+
import org.springframework.dao.DataIntegrityViolationException;
2536
import org.springframework.stereotype.Service;
2637
import org.springframework.transaction.annotation.Propagation;
2738
import org.springframework.transaction.annotation.Transactional;
2839
import org.springframework.transaction.event.TransactionPhase;
2940
import org.springframework.transaction.event.TransactionalEventListener;
3041

3142
import java.util.List;
43+
import java.util.Map;
3244

3345
@Service
3446
@RequiredArgsConstructor
3547
public class SessionService {
3648

49+
private static final String ANSWER_IDEMPOTENCY_CONSUMER = "session.answer";
50+
3751
private final InterviewSessionRepository sessionRepo;
3852
private final SessionContextRepository contextRepo;
3953
private final InterviewMessageRepository messageRepo;
@@ -100,4 +114,93 @@ public void onSessionCreated(SessionCreatedEvent event) {
100114
new MessageContext(event.userId(), event.sessionId(), null, null)
101115
);
102116
}
117+
118+
@Transactional
119+
public MessageResult submitAnswer(MessageSubmitCommand cmd) {
120+
InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(cmd.sessionId())
121+
.orElseThrow(() -> new SessionNotFoundException(cmd.sessionId()));
122+
123+
if (!session.getUser().getId().equals(cmd.userId())) {
124+
throw new SessionForbiddenException(cmd.sessionId());
125+
}
126+
127+
String idemKey = idempotencyKey(cmd.sessionId(), cmd.idempotencyKey());
128+
if (idemKey != null && processedRepo.existsById(idemKey)) {
129+
InterviewMessage last = messageRepo
130+
.findFirstBySession_IdOrderBySequenceNumberDesc(cmd.sessionId())
131+
.orElseThrow(() -> new IllegalStateException("멱등 캐시는 있는데 메시지가 없습니다."));
132+
return MessageResult.from(last);
133+
}
134+
135+
if (session.getStatus() != SessionStatus.IN_PROGRESS) {
136+
throw new SessionInvalidStateException(cmd.sessionId(),
137+
"IN_PROGRESS 가 아닙니다. 현재=" + session.getStatus());
138+
}
139+
140+
InterviewMessage parentQuestion = messageRepo
141+
.findFirstBySession_IdOrderBySequenceNumberDesc(cmd.sessionId())
142+
.orElseThrow(() -> new SessionInvalidStateException(cmd.sessionId(), "직전 질문이 없습니다."));
143+
144+
if (parentQuestion.getRole() != MessageRole.INTERVIEWER) {
145+
throw new SessionInvalidStateException(cmd.sessionId(),
146+
"직전 메시지가 질문이 아닙니다. 꼬리질문을 기다리세요. role=" + parentQuestion.getRole());
147+
}
148+
149+
int nextSeq = messageRepo.findMaxSequenceBySessionId(cmd.sessionId()) + 1;
150+
InterviewMessage answer = messageRepo.save(
151+
InterviewMessage.interviewee(session, nextSeq, cmd.content(), parentQuestion));
152+
153+
if (idemKey != null) {
154+
try {
155+
processedRepo.save(ProcessedMessage.of(idemKey, ANSWER_IDEMPOTENCY_CONSUMER));
156+
} catch (DataIntegrityViolationException ignored) {
157+
// race: 다른 worker 가 먼저 캐싱했으므로 그대로 진행
158+
}
159+
}
160+
161+
events.publishEvent(new AnswerSubmittedEvent(
162+
session.getId(), session.getUser().getId(),
163+
parentQuestion.getId(), answer.getId(), cmd.content()));
164+
165+
return MessageResult.from(answer);
166+
}
167+
168+
@Transactional
169+
public SessionResult end(Long sessionId, Long userId, boolean cancel) {
170+
InterviewSession session = sessionRepo.findByIdAndIsDeletedFalse(sessionId)
171+
.orElseThrow(() -> new SessionNotFoundException(sessionId));
172+
if (!session.getUser().getId().equals(userId)) {
173+
throw new SessionForbiddenException(sessionId);
174+
}
175+
if (session.getStatus() == SessionStatus.COMPLETED || session.getStatus() == SessionStatus.CANCELLED) {
176+
throw new SessionInvalidStateException(sessionId, "이미 종료된 세션입니다. 현재=" + session.getStatus());
177+
}
178+
if (cancel) {
179+
session.cancel();
180+
} else {
181+
session.end();
182+
}
183+
sse.publishToSession(sessionId, SseEventType.SESSION_STATE,
184+
Map.of("sessionId", sessionId, "state", session.getStatus().name(),
185+
"totalQuestionCount", session.getTotalQuestionCount(),
186+
"endedAt", session.getEndedAt()));
187+
return SessionResult.from(session);
188+
}
189+
190+
private String idempotencyKey(Long sessionId, String raw) {
191+
if (raw == null || raw.isBlank()) return null;
192+
return ANSWER_IDEMPOTENCY_CONSUMER + ":" + sessionId + ":" + raw;
193+
}
194+
195+
@Transactional(propagation = Propagation.NOT_SUPPORTED)
196+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
197+
public void onAnswerSubmitted(AnswerSubmittedEvent event) {
198+
publisher.publishToAi(
199+
properties.routingKeys().generateFollowup(),
200+
new GenerateFollowupPayload(
201+
event.sessionId(), event.parentQuestionMessageId(),
202+
event.answerMessageId(), event.answerText(), null),
203+
new MessageContext(event.userId(), event.sessionId(), null, null)
204+
);
205+
}
103206
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.stackup.stackup.session.application.event;
2+
3+
public record AnswerSubmittedEvent(
4+
Long sessionId,
5+
Long userId,
6+
Long parentQuestionMessageId,
7+
Long answerMessageId,
8+
String answerText
9+
) {}

0 commit comments

Comments
 (0)