Skip to content

Commit cf6a2fb

Browse files
authored
feat: SSE 이벤트 기반 종합/최종 평가 피드백 생성 및 클라이언트 푸시 기능 구현
feat: SSE 이벤트 기반 종합/최종 평가 피드백 생성 및 클라이언트 푸시 기능 구현
2 parents 8c60ac0 + bea0fe8 commit cf6a2fb

24 files changed

Lines changed: 550 additions & 16 deletions

src/main/java/io/wisoft/prepair/prepair_api/controller/InterviewController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.wisoft.prepair.prepair_api.entity.enums.QuestionType;
88
import io.wisoft.prepair.prepair_api.global.common.ApiResponse;
99
import io.wisoft.prepair.prepair_api.service.answer.AnswerService;
10+
import io.wisoft.prepair.prepair_api.global.sse.SseEmitterManager;
1011
import io.wisoft.prepair.prepair_api.service.question.QuestionService;
1112
import jakarta.validation.Valid;
1213

@@ -19,6 +20,7 @@
1920
import org.springframework.http.MediaType;
2021
import org.springframework.web.bind.annotation.*;
2122
import org.springframework.web.multipart.MultipartFile;
23+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
2224

2325
@Slf4j
2426
@RestController
@@ -28,6 +30,7 @@ public class InterviewController {
2830

2931
private final QuestionService questionService;
3032
private final AnswerService answerService;
33+
private final SseEmitterManager sseEmitterManager;
3134

3235
@GetMapping("/questions")
3336
public ApiResponse<List<QuestionResponse>> getQuestions(
@@ -86,5 +89,10 @@ public ApiResponse<Void> submitVideoAnswer(
8689
answerService.submitVideoAnswer(questionId, memberId, video);
8790
return ApiResponse.accepted(null, "영상 답변이 제출되었습니다.");
8891
}
92+
93+
@GetMapping(value = "/questions/video-answers/{sessionId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
94+
public SseEmitter subscribeSession(@PathVariable UUID sessionId) {
95+
return sseEmitterManager.create(sessionId);
96+
}
8997
}
9098

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package io.wisoft.prepair.prepair_api.dto;
2+
3+
public record CombinedFeedbackResult(
4+
String combineFeedback,
5+
int score
6+
) {
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.wisoft.prepair.prepair_api.dto;
2+
3+
public record FinalFeedbackResult(
4+
String finalFeedback
5+
) {
6+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.wisoft.prepair.prepair_api.dto.response;
2+
3+
import java.util.List;
4+
import java.util.UUID;
5+
6+
public record FinalFeedbackResponse(
7+
UUID sessionId,
8+
int finalScore,
9+
String summary,
10+
List<QuestionFeedback> questions
11+
) {
12+
public record QuestionFeedback(
13+
UUID questionId,
14+
String question,
15+
int combinedScore,
16+
String combinedFeedback,
17+
String sttFeedback,
18+
String videoFeedback
19+
) {
20+
}
21+
}

src/main/java/io/wisoft/prepair/prepair_api/dto/response/QuestionResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public record QuestionResponse(
1515
QuestionStatus status,
1616
Integer latestScore,
1717
UUID jobPostingId,
18+
UUID sessionId,
1819
LocalDateTime createdAt
1920
) {
2021

@@ -27,6 +28,7 @@ public static QuestionResponse from(InterviewQuestion question) {
2728
question.getStatus(),
2829
question.getLatestScore(),
2930
question.getJobPosting() != null ? question.getJobPosting().getId() : null,
31+
question.getInterviewSession() != null ? question.getInterviewSession().getId() : null,
3032
question.getCreatedAt()
3133
);
3234
}

src/main/java/io/wisoft/prepair/prepair_api/entity/InterviewQuestion.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,24 @@ public class InterviewQuestion extends BaseTimeEntity {
5656
@JoinColumn(name = "job_posting_id")
5757
private JobPosting jobPosting;
5858

59+
@ManyToOne(fetch = FetchType.LAZY)
60+
@JoinColumn(name = "session_id")
61+
private InterviewSession interviewSession;
62+
5963
public InterviewQuestion(
6064
final UUID memberId,
6165
final String question,
6266
final QuestionType questionType,
6367
final String questionTag,
64-
final JobPosting jobPosting
68+
final JobPosting jobPosting,
69+
final InterviewSession interviewSession
6570
) {
6671
this.memberId = memberId;
6772
this.question = question;
6873
this.questionType = questionType;
6974
this.questionTag = questionTag;
7075
this.jobPosting = jobPosting;
76+
this.interviewSession = interviewSession;
7177
}
7278

7379
public void updateStatus(final QuestionStatus status) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.wisoft.prepair.prepair_api.entity;
2+
3+
import io.wisoft.prepair.prepair_api.entity.enums.SessionStatus;
4+
import io.wisoft.prepair.prepair_api.global.common.BaseTimeEntity;
5+
import jakarta.persistence.*;
6+
import lombok.AccessLevel;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
import java.util.UUID;
11+
12+
@Entity
13+
@Table(name = "interview_session")
14+
@Getter
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
public class InterviewSession extends BaseTimeEntity {
17+
18+
@Id
19+
@GeneratedValue(strategy = GenerationType.UUID)
20+
private UUID id;
21+
22+
@Column(nullable = false)
23+
private UUID memberId;
24+
25+
@Enumerated(EnumType.STRING)
26+
@Column(nullable = false)
27+
private SessionStatus status;
28+
29+
@Column(nullable = false)
30+
private int totalQuestionCount;
31+
32+
@Column
33+
private Integer finalScore;
34+
35+
@Column(columnDefinition = "TEXT")
36+
private String finalFeedback;
37+
38+
public InterviewSession(UUID memberId, int totalQuestionCount) {
39+
this.memberId = memberId;
40+
this.totalQuestionCount = totalQuestionCount;
41+
this.status = SessionStatus.IN_PROGRESS;
42+
}
43+
44+
public void complete(int finalScore, String finalFeedback) {
45+
this.finalScore = finalScore;
46+
this.finalFeedback = finalFeedback;
47+
this.status = SessionStatus.COMPLETED;
48+
}
49+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.wisoft.prepair.prepair_api.entity.enums;
2+
3+
public enum SessionStatus {
4+
IN_PROGRESS,
5+
COMPLETED
6+
}

src/main/java/io/wisoft/prepair/prepair_api/global/config/AsyncConfig.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ public class AsyncConfig {
1414
@Bean
1515
public Executor videoTaskExecutor() {
1616
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
17-
executor.setCorePoolSize(2);
18-
executor.setMaxPoolSize(5);
17+
executor.setCorePoolSize(3);
18+
executor.setMaxPoolSize(6);
1919
executor.setQueueCapacity(10);
2020
executor.setThreadNamePrefix("video-task");
2121
return executor;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.wisoft.prepair.prepair_api.global.sse;
2+
3+
import java.io.IOException;
4+
import java.util.UUID;
5+
import java.util.concurrent.ConcurrentHashMap;
6+
import java.util.concurrent.ConcurrentMap;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
10+
11+
@Slf4j
12+
@Component
13+
public class SseEmitterManager {
14+
15+
private static final long TIMEOUT = 5 * 60 * 1000L;
16+
private final ConcurrentMap<UUID, SseEmitter> emitters = new ConcurrentHashMap<>();
17+
18+
public SseEmitter create(UUID id) {
19+
SseEmitter emitter = new SseEmitter(TIMEOUT);
20+
emitters.put(id, emitter);
21+
22+
emitter.onCompletion(() -> emitters.remove(id));
23+
emitter.onTimeout(() -> emitters.remove(id));
24+
emitter.onError(e -> emitters.remove(id));
25+
26+
return emitter;
27+
}
28+
29+
public void send(UUID id, String eventName, Object data) {
30+
SseEmitter emitter = emitters.get(id);
31+
if (emitter == null) return;
32+
33+
try {
34+
emitter.send(SseEmitter.event()
35+
.name(eventName)
36+
.data(data));
37+
} catch (IOException e) {
38+
log.error("[SSE] 전송 실패 - id: {}", id, e);
39+
emitters.remove(id);
40+
}
41+
}
42+
43+
public void complete(UUID id) {
44+
SseEmitter emitter = emitters.remove(id);
45+
if (emitter != null) {
46+
emitter.complete();
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)