|
3 | 3 | import com.stackup.stackup.common.config.properties.RabbitMqProperties; |
4 | 4 | import com.stackup.stackup.common.messaging.MessageContext; |
5 | 5 | import com.stackup.stackup.common.messaging.RabbitMessagePublisher; |
| 6 | +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; |
6 | 7 | import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; |
7 | 8 | import com.stackup.stackup.common.sse.SseEventPublisher; |
| 9 | +import com.stackup.stackup.common.sse.SseEventType; |
8 | 10 | import com.stackup.stackup.document.domain.AnalyzedDocument; |
9 | 11 | import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; |
| 12 | +import com.stackup.stackup.session.application.dto.GenerateFollowupPayload; |
10 | 13 | 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; |
11 | 16 | import com.stackup.stackup.session.application.dto.SessionCreateCommand; |
12 | 17 | import com.stackup.stackup.session.application.dto.SessionResult; |
| 18 | +import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; |
13 | 19 | import com.stackup.stackup.session.application.event.SessionCreatedEvent; |
14 | 20 | 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; |
15 | 23 | import com.stackup.stackup.session.domain.InterviewMessage; |
16 | 24 | import com.stackup.stackup.session.domain.InterviewMessageRepository; |
17 | 25 | import com.stackup.stackup.session.domain.InterviewSession; |
18 | 26 | import com.stackup.stackup.session.domain.InterviewSessionRepository; |
| 27 | +import com.stackup.stackup.session.domain.MessageRole; |
19 | 28 | import com.stackup.stackup.session.domain.SessionContext; |
20 | 29 | import com.stackup.stackup.session.domain.SessionContextRepository; |
| 30 | +import com.stackup.stackup.session.domain.SessionStatus; |
21 | 31 | import com.stackup.stackup.user.domain.User; |
22 | 32 | import com.stackup.stackup.user.domain.UserRepository; |
23 | 33 | import lombok.RequiredArgsConstructor; |
24 | 34 | import org.springframework.context.ApplicationEventPublisher; |
| 35 | +import org.springframework.dao.DataIntegrityViolationException; |
25 | 36 | import org.springframework.stereotype.Service; |
26 | 37 | import org.springframework.transaction.annotation.Propagation; |
27 | 38 | import org.springframework.transaction.annotation.Transactional; |
28 | 39 | import org.springframework.transaction.event.TransactionPhase; |
29 | 40 | import org.springframework.transaction.event.TransactionalEventListener; |
30 | 41 |
|
31 | 42 | import java.util.List; |
| 43 | +import java.util.Map; |
32 | 44 |
|
33 | 45 | @Service |
34 | 46 | @RequiredArgsConstructor |
35 | 47 | public class SessionService { |
36 | 48 |
|
| 49 | + private static final String ANSWER_IDEMPOTENCY_CONSUMER = "session.answer"; |
| 50 | + |
37 | 51 | private final InterviewSessionRepository sessionRepo; |
38 | 52 | private final SessionContextRepository contextRepo; |
39 | 53 | private final InterviewMessageRepository messageRepo; |
@@ -100,4 +114,93 @@ public void onSessionCreated(SessionCreatedEvent event) { |
100 | 114 | new MessageContext(event.userId(), event.sessionId(), null, null) |
101 | 115 | ); |
102 | 116 | } |
| 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 | + } |
103 | 206 | } |
0 commit comments