Skip to content

Commit ba2799b

Browse files
committed
feat(session): 면접 세션 도메인 + Core/AI 메시지 DTO
1 parent af78d48 commit ba2799b

12 files changed

Lines changed: 499 additions & 0 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.stackup.stackup.session.application.dto;
2+
3+
public record GenerateFollowupPayload(
4+
Long sessionId,
5+
Long questionMessageId,
6+
Long answerMessageId,
7+
String answerText,
8+
String audioS3Key // 텍스트 면접에서는 null
9+
) {
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.stackup.stackup.session.application.dto;
2+
3+
import java.util.List;
4+
5+
public record GenerateQuestionsPayload(
6+
Long sessionId,
7+
String interviewType,
8+
String jobCategory,
9+
List<Long> documentIds,
10+
int maxQuestions
11+
) {
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.stackup.stackup.session.application.dto;
2+
3+
import com.stackup.stackup.common.messaging.MessageContext;
4+
import java.time.Instant;
5+
6+
public record QuestionsCallbackEnvelope(
7+
String messageId,
8+
String messageType,
9+
String version,
10+
String traceId,
11+
Instant publishedAt,
12+
String publisher,
13+
QuestionsCallbackPayload payload,
14+
MessageContext context
15+
) {}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.stackup.stackup.session.application.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import java.util.Map;
5+
6+
@JsonInclude(JsonInclude.Include.NON_NULL)
7+
public record QuestionsCallbackPayload(
8+
Long sessionId,
9+
String kind, // "FIRST" | "FOLLOWUP" | "END"
10+
String category, // FIRST / FOLLOWUP 일 때: PROJECT_DEEP_DIVE / CS_FUNDAMENTAL 등
11+
String question, // FIRST / FOLLOWUP 일 때: 질문 본문
12+
Long parentMessageId, // FOLLOWUP 일 때: 직전 답변 메시지 id
13+
Map<String, Object> answerEvaluation, // FOLLOWUP / END 일 때: { specificity, logic, structure }
14+
Map<String, Object> voiceAnalysis // 음성 모드, 텍스트 면접에선 null
15+
) {
16+
public boolean isFirst() { return "FIRST".equals(kind); }
17+
public boolean isFollowup() { return "FOLLOWUP".equals(kind); }
18+
public boolean isEnd() { return "END".equals(kind); }
19+
}

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,50 @@ public class InterviewMessage extends BaseTimeEntity {
6161
@Column(nullable = false, length = 20)
6262
@Enumerated(EnumType.STRING)
6363
private MessageStatus status = MessageStatus.CREATED;
64+
65+
public static InterviewMessage interviewer(
66+
InterviewSession session, int sequence, String content, InterviewMessage parent
67+
) {
68+
if (session == null) {
69+
throw new IllegalArgumentException("session must not be null");
70+
}
71+
if (content == null || content.isBlank()) {
72+
throw new IllegalArgumentException("content must not be null or blank");
73+
}
74+
if (sequence < 1) {
75+
throw new IllegalArgumentException("sequence must be >= 1");
76+
}
77+
78+
InterviewMessage m = new InterviewMessage();
79+
m.session = session;
80+
m.sequenceNumber = sequence;
81+
m.role = MessageRole.INTERVIEWER;
82+
m.content = content;
83+
m.parentMessage = parent;
84+
m.status = MessageStatus.CREATED;
85+
return m;
86+
}
87+
88+
public static InterviewMessage interviewee(
89+
InterviewSession session, int sequence, String content, InterviewMessage parent
90+
) {
91+
if (session == null) {
92+
throw new IllegalArgumentException("session must not be null");
93+
}
94+
if (content == null || content.isBlank()) {
95+
throw new IllegalArgumentException("content must not be null or blank");
96+
}
97+
if (sequence < 1) {
98+
throw new IllegalArgumentException("sequence must be >= 1");
99+
}
100+
101+
InterviewMessage m = new InterviewMessage();
102+
m.session = session;
103+
m.sequenceNumber = sequence;
104+
m.role = MessageRole.INTERVIEWEE;
105+
m.content = content;
106+
m.parentMessage = parent;
107+
m.status = MessageStatus.COMPLETED; // 답변은 작성 시점에 완료
108+
return m;
109+
}
64110
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
package com.stackup.stackup.session.domain;
22

33
import java.util.List;
4+
import java.util.Optional;
45
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
58

69
public interface InterviewMessageRepository extends JpaRepository<InterviewMessage, Long> {
710

811
List<InterviewMessage> findBySession_IdOrderBySequenceNumberAsc(Long sessionId);
12+
13+
@Query("select coalesce(max(m.sequenceNumber), 0) from InterviewMessage m where m.session.id = :sessionId")
14+
int findMaxSequenceBySessionId(@Param("sessionId") Long sessionId);
15+
16+
Optional<InterviewMessage> findFirstBySession_IdOrderBySequenceNumberDesc(Long sessionId);
917
}

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,72 @@ public class InterviewSession extends BaseSoftDeleteEntity {
7676

7777
@Column(name = "ended_at")
7878
private Instant endedAt;
79+
80+
public static InterviewSession create(
81+
User user, String title, String memo,
82+
SessionMode mode, InterviewType interviewType, JobCategory jobCategory,
83+
Integer maxQuestions, Integer maxDurationMinutes
84+
) {
85+
if (user == null) {
86+
throw new IllegalArgumentException("user must not be null");
87+
}
88+
if (mode == null) {
89+
throw new IllegalArgumentException("mode must not be null");
90+
}
91+
if (interviewType == null) {
92+
throw new IllegalArgumentException("interviewType must not be null");
93+
}
94+
if (jobCategory == null) {
95+
throw new IllegalArgumentException("jobCategory must not be null");
96+
}
97+
InterviewSession s = new InterviewSession();
98+
s.user = user;
99+
s.title = title;
100+
s.memo = memo;
101+
s.mode = mode;
102+
s.interviewType = interviewType;
103+
s.jobCategory = jobCategory;
104+
s.maxQuestions = maxQuestions == null ? 10 : maxQuestions;
105+
s.maxDurationMinutes = maxDurationMinutes == null ? 60 : maxDurationMinutes;
106+
s.status = SessionStatus.READY;
107+
s.totalQuestionCount = 0;
108+
return s;
109+
}
110+
111+
public void markInProgress() {
112+
if (this.status != SessionStatus.READY) {
113+
throw new IllegalStateException("IN_PROGRESS 전이는 READY 상태에서만 가능합니다. 현재=" + this.status);
114+
}
115+
this.status = SessionStatus.IN_PROGRESS;
116+
this.startedAt = Instant.now();
117+
}
118+
119+
public void incrementQuestionCount() {
120+
if (this.status != SessionStatus.IN_PROGRESS) {
121+
throw new IllegalStateException("IN_PROGRESS 상태에서만 증가할 수 있습니다.");
122+
}
123+
this.totalQuestionCount = (this.totalQuestionCount == null ? 0 : this.totalQuestionCount) + 1;
124+
}
125+
126+
public boolean isMaxReached() {
127+
return this.totalQuestionCount != null
128+
&& this.maxQuestions != null
129+
&& this.totalQuestionCount >= this.maxQuestions;
130+
}
131+
132+
public void end() {
133+
if (this.status != SessionStatus.IN_PROGRESS) {
134+
throw new IllegalStateException("IN_PROGRESS 상태에서만 종료할 수 있습니다. 현재=" + this.status);
135+
}
136+
this.status = SessionStatus.COMPLETED;
137+
this.endedAt = Instant.now();
138+
}
139+
140+
public void cancel() {
141+
if (this.status == SessionStatus.COMPLETED || this.status == SessionStatus.CANCELLED) {
142+
throw new IllegalStateException("취소할 수 없는 상태입니다. 현재=" + this.status);
143+
}
144+
this.status = SessionStatus.CANCELLED;
145+
this.endedAt = Instant.now();
146+
}
79147
}

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

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

33
import java.util.List;
44
import java.util.Optional;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
57
import org.springframework.data.jpa.repository.JpaRepository;
68

79
public interface InterviewSessionRepository extends JpaRepository<InterviewSession, Long> {
810

911
List<InterviewSession> findByUser_Id(Long userId);
1012

1113
Optional<InterviewSession> findByIdAndUser_Id(Long id, Long userId);
14+
15+
Page<InterviewSession> findByUser_IdAndIsDeletedFalseOrderByCreatedAtDesc(Long userId, Pageable pageable);
16+
17+
Optional<InterviewSession> findByIdAndIsDeletedFalse(Long id);
1218
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.stackup.stackup.session.domain;
22

3+
import java.util.List;
34
import java.util.Optional;
45
import org.springframework.data.jpa.repository.JpaRepository;
56

67
public interface SessionContextRepository extends JpaRepository<SessionContext, Long> {
78

89
Optional<SessionContext> findBySession_Id(Long sessionId);
10+
11+
List<SessionContext> findAllBySession_Id(Long sessionId);
912
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.stackup.stackup.session.application.dto;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.json.JsonMapper;
5+
import org.junit.jupiter.api.Test;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
class QuestionsCallbackPayloadTest {
10+
private final ObjectMapper m = JsonMapper.builder().findAndAddModules().build();
11+
12+
@Test
13+
void first_payload_round_trip() throws Exception {
14+
String json = """
15+
{"sessionId":99,"kind":"FIRST",
16+
"category":"PROJECT_DEEP_DIVE","question":"Q1"}""";
17+
QuestionsCallbackPayload p = m.readValue(json, QuestionsCallbackPayload.class);
18+
assertThat(p.isFirst()).isTrue();
19+
assertThat(p.category()).isEqualTo("PROJECT_DEEP_DIVE");
20+
assertThat(p.question()).isEqualTo("Q1");
21+
}
22+
23+
@Test
24+
void followup_payload_round_trip() throws Exception {
25+
String json = """
26+
{"sessionId":99,"kind":"FOLLOWUP","parentMessageId":502,
27+
"category":"CS_FUNDAMENTAL","question":"왜?",
28+
"answerEvaluation":{"specificity":3.5}}""";
29+
QuestionsCallbackPayload p = m.readValue(json, QuestionsCallbackPayload.class);
30+
assertThat(p.isFollowup()).isTrue();
31+
assertThat(p.parentMessageId()).isEqualTo(502L);
32+
assertThat(p.question()).isEqualTo("왜?");
33+
}
34+
35+
@Test
36+
void end_payload_round_trip() throws Exception {
37+
String json = """
38+
{"sessionId":99,"kind":"END",
39+
"answerEvaluation":{"specificity":4.1}}""";
40+
QuestionsCallbackPayload p = m.readValue(json, QuestionsCallbackPayload.class);
41+
assertThat(p.isEnd()).isTrue();
42+
assertThat(p.question()).isNull();
43+
}
44+
}

0 commit comments

Comments
 (0)