diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/SubmissionErrorEvent.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/SubmissionErrorEvent.java new file mode 100644 index 00000000..1cca6340 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/SubmissionErrorEvent.java @@ -0,0 +1,23 @@ +package org.ezcode.codetest.application.submission.dto.event; + +import org.ezcode.codetest.domain.submission.exception.SubmissionException; +import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; + +public record SubmissionErrorEvent( + + String sessionKey, + + SubmissionExceptionCode code + +) { + public SubmissionErrorEvent(String sessionKey, Throwable t) { + this(sessionKey, resolveCode(t)); + } + + private static SubmissionExceptionCode resolveCode(Throwable t) { + if (t instanceof SubmissionException se) { + return (SubmissionExceptionCode) se.getResponseCode(); + } + return SubmissionExceptionCode.UNKNOWN_ERROR; + } +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/SubmissionJudgingFinishedEvent.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/SubmissionJudgingFinishedEvent.java new file mode 100644 index 00000000..1d31bbcc --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/SubmissionJudgingFinishedEvent.java @@ -0,0 +1,12 @@ +package org.ezcode.codetest.application.submission.dto.event; + +import org.ezcode.codetest.application.submission.dto.event.payload.SubmissionFinalResultPayload; + +public record SubmissionJudgingFinishedEvent( + + String sessionKey, + + SubmissionFinalResultPayload payload + +) { +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/TestcaseEvaluatedEvent.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/TestcaseEvaluatedEvent.java new file mode 100644 index 00000000..3b8b26ca --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/TestcaseEvaluatedEvent.java @@ -0,0 +1,18 @@ +package org.ezcode.codetest.application.submission.dto.event; + +import org.ezcode.codetest.application.submission.dto.event.payload.TestcaseResultPayload; + +public record TestcaseEvaluatedEvent( + + String sessionKey, + + TestcaseResultPayload payload + +) { + public static TestcaseEvaluatedEvent of(String sessionKey, TestcaseResultPayload payload) { + return new TestcaseEvaluatedEvent( + sessionKey, + payload + ); + } +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/TestcaseListInitializedEvent.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/TestcaseListInitializedEvent.java new file mode 100644 index 00000000..5e0c58de --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/TestcaseListInitializedEvent.java @@ -0,0 +1,17 @@ +package org.ezcode.codetest.application.submission.dto.event; + +import java.util.List; + +import org.ezcode.codetest.application.submission.dto.event.payload.InitTestcaseListPayload; + +public record TestcaseListInitializedEvent( + + String sessionKey, + + List payload + +) { + public static TestcaseListInitializedEvent from(String sessionKey, List payload) { + return new TestcaseListInitializedEvent(sessionKey, payload); + } +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/InitTestcaseListPayload.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/InitTestcaseListPayload.java new file mode 100644 index 00000000..26513876 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/InitTestcaseListPayload.java @@ -0,0 +1,33 @@ +package org.ezcode.codetest.application.submission.dto.event.payload; + +import java.util.List; +import java.util.stream.IntStream; + +import org.ezcode.codetest.domain.problem.model.ProblemInfo; +import org.ezcode.codetest.domain.problem.model.entity.Testcase; + +public record InitTestcaseListPayload( + + int seqId, + + String input, + + String expectedOutput, + + String status + +) { + public static List from(ProblemInfo info) { + return IntStream.rangeClosed(1, info.getTestcaseCount()) + .mapToObj(seq -> { + Testcase tc = info.testcaseList().get(seq - 1); + return new InitTestcaseListPayload( + seq, + tc.getInput(), + tc.getOutput(), + "채점 중" + ); + }) + .toList(); + } +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/SubmissionFinalResultPayload.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/SubmissionFinalResultPayload.java new file mode 100644 index 00000000..f91eed50 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/SubmissionFinalResultPayload.java @@ -0,0 +1,22 @@ +package org.ezcode.codetest.application.submission.dto.event.payload; + +import lombok.Getter; + +@Getter +public class SubmissionFinalResultPayload { + + private final int totalCount; + + private final int passedCount; + + private final boolean isCorrect; + + private final String message; + + public SubmissionFinalResultPayload(int totalCount, int passedCount, String message) { + this.totalCount = totalCount; + this.passedCount = passedCount; + this.isCorrect = totalCount == passedCount; + this.message = message; + } +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/TestcaseResultPayload.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/TestcaseResultPayload.java new file mode 100644 index 00000000..e0579a60 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/payload/TestcaseResultPayload.java @@ -0,0 +1,30 @@ +package org.ezcode.codetest.application.submission.dto.event.payload; + +import org.ezcode.codetest.application.submission.model.JudgeResult; +import org.ezcode.codetest.domain.submission.dto.AnswerEvaluation; + +public record TestcaseResultPayload( + + int seqId, + + boolean isPassed, + + String actualOutput, + + long executionTime, + + long memoryUsage, + + String message + +) { + public static TestcaseResultPayload fromEvaluation(int seqId, JudgeResult result, AnswerEvaluation evaluation) { + return new TestcaseResultPayload( + seqId, + evaluation.isPassed(), + result.actualOutput(), + result.executionTime(), + result.memoryUsage(), + result.message()); + } +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/FinalResultResponse.java b/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/FinalResultResponse.java deleted file mode 100644 index c8e2503e..00000000 --- a/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/FinalResultResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.ezcode.codetest.application.submission.dto.response.submission; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -@Getter -@Schema(description = "최종 채점 결과 응답 DTO") -public class FinalResultResponse { - - @Schema(description = "전체 테스트케이스 수", example = "5") - private final int totalCount; - - @Schema(description = "통과한 테스트케이스 수", example = "5") - private final int passedCount; - - @Schema(description = "전체 통과 여부", example = "true") - private final boolean isCorrect; - - @Schema(description = "메시지", example = "Accepted") - private final String message; - - public FinalResultResponse(int totalCount, int passedCount, String message) { - this.totalCount = totalCount; - this.passedCount = passedCount; - this.isCorrect = totalCount == passedCount; - this.message = message; - } -} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/JudgeResultResponse.java b/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/JudgeResultResponse.java deleted file mode 100644 index c83f5c91..00000000 --- a/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/JudgeResultResponse.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.ezcode.codetest.application.submission.dto.response.submission; - -import org.ezcode.codetest.application.submission.model.JudgeResult; -import org.ezcode.codetest.domain.submission.dto.AnswerEvaluation; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; - -@Builder -@Schema(description = "각 테스트케이스에 대한 채점 결과") -public record JudgeResultResponse( - - @Schema(description = "테스트케이스 통과 여부", example = "true") - boolean isPassed, - - @Schema(description = "기댓값", example = "12") - String expectedOutput, - - @Schema(description = "실제 출력값", example = "12") - String actualOutput, - - @Schema(description = "실행 시간 (s)", example = "0.129") - Double executionTime, - - @Schema(description = "메모리 사용량 (KB)", example = "12196") - Long memoryUsage, - - @Schema(description = "결과 메시지", example = "Accepted") - String message - -) { - public static JudgeResultResponse fromEvaluation(JudgeResult result, AnswerEvaluation evaluation) { - return new JudgeResultResponse( - evaluation.isPassed(), - evaluation.expectedOutput(), - evaluation.actualOutput(), - result.executionTime(), - result.memoryUsage(), - result.message()); - } -} diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/SubmissionDetailResponse.java b/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/SubmissionDetailResponse.java index d47b8023..9fe3e931 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/SubmissionDetailResponse.java +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/response/submission/SubmissionDetailResponse.java @@ -22,7 +22,7 @@ public record SubmissionDetailResponse( String message, @Schema(description = "실행 시간 (s)", example = "0.129") - Double executionTime, + Long executionTime, @Schema(description = "메모리 사용량 (KB)", example = "12196") Long memoryUsage, diff --git a/src/main/java/org/ezcode/codetest/application/submission/model/JudgeResult.java b/src/main/java/org/ezcode/codetest/application/submission/model/JudgeResult.java index fdb46018..801dd900 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/model/JudgeResult.java +++ b/src/main/java/org/ezcode/codetest/application/submission/model/JudgeResult.java @@ -7,7 +7,7 @@ public record JudgeResult( String actualOutput, - double executionTime, + long executionTime, long memoryUsage, diff --git a/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java b/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java index 23920c47..2100c193 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java +++ b/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java @@ -1,6 +1,6 @@ package org.ezcode.codetest.application.submission.model; -import org.ezcode.codetest.application.submission.dto.response.submission.FinalResultResponse; +import org.ezcode.codetest.application.submission.dto.event.payload.SubmissionFinalResultPayload; import org.ezcode.codetest.domain.submission.model.SubmissionAggregator; import java.util.concurrent.CountDownLatch; @@ -33,8 +33,8 @@ public static SubmissionContext initialize(int totalTestcaseCount) { ); } - public FinalResultResponse toFinalResult(int totalTestcaseCount) { - return new FinalResultResponse( + public SubmissionFinalResultPayload toFinalResult(int totalTestcaseCount) { + return new SubmissionFinalResultPayload( totalTestcaseCount, this.getPassedCount(), this.getCurrentMessage() @@ -53,10 +53,6 @@ public int getPassedCount() { return this.passedCount.get(); } - public int getProcessedCount() { - return this.processedCount.get(); - } - public String getCurrentMessage() { return this.message.get(); } diff --git a/src/main/java/org/ezcode/codetest/application/submission/port/EmitterStore.java b/src/main/java/org/ezcode/codetest/application/submission/port/EmitterStore.java deleted file mode 100644 index e7376d5c..00000000 --- a/src/main/java/org/ezcode/codetest/application/submission/port/EmitterStore.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.ezcode.codetest.application.submission.port; - -import java.util.Optional; - -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -public interface EmitterStore { - - void saveWithCallbacks(String key, SseEmitter emitter); - - Optional get(String key); - - SseEmitter getOrElseThrow(String key); - - void remove(String key); - -} diff --git a/src/main/java/org/ezcode/codetest/application/submission/port/ProblemEventService.java b/src/main/java/org/ezcode/codetest/application/submission/port/ProblemEventService.java new file mode 100644 index 00000000..f2cb4fa3 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/port/ProblemEventService.java @@ -0,0 +1,9 @@ +package org.ezcode.codetest.application.submission.port; + +import org.ezcode.codetest.domain.submission.model.entity.UserProblemResult; + +public interface ProblemEventService { + + void publishProblemSolveEvent(UserProblemResult event); + +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/port/QueueProducer.java b/src/main/java/org/ezcode/codetest/application/submission/port/QueueProducer.java index 36a9a654..c54a11be 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/port/QueueProducer.java +++ b/src/main/java/org/ezcode/codetest/application/submission/port/QueueProducer.java @@ -1,6 +1,6 @@ package org.ezcode.codetest.application.submission.port; -import org.ezcode.codetest.infrastructure.event.dto.SubmissionMessage; +import org.ezcode.codetest.infrastructure.event.dto.submission.SubmissionMessage; public interface QueueProducer { diff --git a/src/main/java/org/ezcode/codetest/application/submission/port/SubmissionEventService.java b/src/main/java/org/ezcode/codetest/application/submission/port/SubmissionEventService.java new file mode 100644 index 00000000..3cc4a98a --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/port/SubmissionEventService.java @@ -0,0 +1,17 @@ +package org.ezcode.codetest.application.submission.port; + +import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; +import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseEvaluatedEvent; + +public interface SubmissionEventService { + + void publishInitTestcases(TestcaseListInitializedEvent event); + + void publishTestcaseUpdate(TestcaseEvaluatedEvent event); + + void publishFinalResult(SubmissionJudgingFinishedEvent event); + + void publishSubmissionError(SubmissionErrorEvent event); +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java b/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java index cd2547f0..de3fb60c 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java +++ b/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java @@ -8,28 +8,35 @@ import java.util.concurrent.TimeUnit; import org.ezcode.codetest.application.submission.aop.CodeReviewLock; +import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; +import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseEvaluatedEvent; +import org.ezcode.codetest.application.submission.dto.event.payload.InitTestcaseListPayload; +import org.ezcode.codetest.application.submission.dto.event.payload.TestcaseResultPayload; import org.ezcode.codetest.application.submission.dto.request.review.CodeReviewRequest; import org.ezcode.codetest.application.submission.dto.request.review.ReviewPayload; import org.ezcode.codetest.application.submission.dto.response.review.CodeReviewResponse; import org.ezcode.codetest.application.submission.dto.response.submission.GroupedSubmissionResponse; +import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; import org.ezcode.codetest.application.submission.model.JudgeResult; import org.ezcode.codetest.application.submission.model.ReviewResult; import org.ezcode.codetest.application.submission.model.SubmissionContext; import org.ezcode.codetest.application.submission.port.ExceptionNotifier; import org.ezcode.codetest.application.submission.port.LockManager; +import org.ezcode.codetest.application.submission.port.ProblemEventService; import org.ezcode.codetest.application.submission.port.QueueProducer; +import org.ezcode.codetest.application.submission.port.SubmissionEventService; import org.ezcode.codetest.domain.submission.exception.SubmissionException; import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; import org.ezcode.codetest.domain.submission.model.TestcaseEvaluationInput; -import org.ezcode.codetest.infrastructure.event.dto.SubmissionMessage; -import org.ezcode.codetest.application.submission.port.EmitterStore; +import org.ezcode.codetest.domain.submission.model.entity.UserProblemResult; +import org.ezcode.codetest.infrastructure.event.dto.submission.SubmissionMessage; import org.ezcode.codetest.application.submission.port.ReviewClient; import org.ezcode.codetest.domain.problem.model.ProblemInfo; import org.ezcode.codetest.domain.submission.dto.AnswerEvaluation; import org.ezcode.codetest.domain.submission.dto.SubmissionData; import org.ezcode.codetest.application.submission.dto.request.compile.CodeCompileRequest; import org.ezcode.codetest.application.submission.dto.request.submission.CodeSubmitRequest; -import org.ezcode.codetest.application.submission.dto.response.submission.JudgeResultResponse; import org.ezcode.codetest.application.submission.port.JudgeClient; import org.ezcode.codetest.domain.language.model.entity.Language; import org.ezcode.codetest.domain.problem.model.entity.Problem; @@ -44,7 +51,6 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -60,13 +66,14 @@ public class SubmissionService { private final ProblemDomainService problemDomainService; private final LanguageDomainService languageDomainService; private final SubmissionDomainService submissionDomainService; - private final EmitterStore emitterStore; private final QueueProducer queueProducer; private final Executor judgeTestcaseExecutor; private final ExceptionNotifier exceptionNotifier; private final LockManager lockManager; + private final SubmissionEventService submissionEventService; + private final ProblemEventService problemEventService; - public SseEmitter enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) { + public String enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) { boolean acquired = lockManager.tryLock("submission", authUser.getId(), problemId); @@ -74,59 +81,59 @@ public SseEmitter enqueueCodeSubmission(Long problemId, CodeSubmitRequest reques throw new SubmissionException(SubmissionExceptionCode.ALREADY_JUDGING); } - SseEmitter emitter = new SseEmitter(10 * 60 * 1000L); - String emitterKey = authUser.getId() + "_" + UUID.randomUUID(); - - log.info("[SSE 저장] emitterKey: {}", emitterKey); - emitterStore.saveWithCallbacks(emitterKey, emitter); + String sessionKey = authUser.getId() + "_" + UUID.randomUUID(); queueProducer.enqueue( - new SubmissionMessage(emitterKey, problemId, request.languageId(), authUser.getId(), request.sourceCode()) + new SubmissionMessage(sessionKey, problemId, request.languageId(), authUser.getId(), request.sourceCode()) ); - return emitter; + return sessionKey; } @Async("judgeSubmissionExecutor") public void submitCodeStream(SubmissionMessage msg) { try { log.info("[Submission RUN] Thread = {}", Thread.currentThread().getName()); - log.info("[큐 수신] SubmissionMessage.emitterKey: {}", msg.emitterKey()); + log.info("[큐 수신] SubmissionMessage.sessionKey: {}", msg.sessionKey()); User user = userDomainService.getUserById(msg.userId()); Language language = languageDomainService.getLanguage(msg.languageId()); ProblemInfo problemInfo = problemDomainService.getProblemInfo(msg.problemId()); - SseEmitter emitter = emitterStore.getOrElseThrow(msg.emitterKey()); + List testcaseList = problemInfo.testcaseList(); int totalTestcaseCount = problemInfo.getTestcaseCount(); + SubmissionContext context = SubmissionContext.initialize(totalTestcaseCount); - for (Testcase tc : problemInfo.testcaseList()) { - runTestcaseAsync(tc, msg, language.getJudge0Id(), problemInfo, context, emitter); + submissionEventService.publishInitTestcases( + new TestcaseListInitializedEvent(msg.sessionKey(), InitTestcaseListPayload.from(problemInfo)) + ); + + for (int i = 0; i < totalTestcaseCount; i++) { + int seqId = i + 1; + runTestcaseAsync(seqId, testcaseList.get(i), msg, language.getJudge0Id(), problemInfo, context); } - if (!context.latch().await(60, TimeUnit.SECONDS)) { - emitter.completeWithError(new SubmissionException(SubmissionExceptionCode.TESTCASE_TIMEOUT)); - return; + if (!context.latch().await(100, TimeUnit.SECONDS)) { + throw new SubmissionException(SubmissionExceptionCode.TESTCASE_TIMEOUT); } - emitter.send(SseEmitter.event() - .name("final") - .data(context.toFinalResult(totalTestcaseCount))); - emitter.complete(); + submissionEventService.publishFinalResult( + new SubmissionJudgingFinishedEvent(msg.sessionKey(), context.toFinalResult(totalTestcaseCount)) + ); SubmissionData submissionData = SubmissionData.base( user, problemInfo, language, msg.sourceCode(), context.getCurrentMessage() ); - submissionDomainService.finalizeSubmission( + UserProblemResult userProblemResult = submissionDomainService.finalizeSubmission( submissionData, context.aggregator(), context.getPassedCount() ); + problemEventService.publishProblemSolveEvent(userProblemResult); } catch (Exception e) { - emitterStore.get(msg.emitterKey()).ifPresent(emitter -> emitter.completeWithError(e)); + submissionEventService.publishSubmissionError(new SubmissionErrorEvent(msg.sessionKey(), e)); exceptionNotificationHelper(e); } finally { - emitterStore.remove(msg.emitterKey()); lockManager.releaseLock("submission", msg.userId(), msg.problemId()); } } @@ -154,8 +161,8 @@ public CodeReviewResponse getCodeReview(Long problemId, CodeReviewRequest reques } private void runTestcaseAsync( - Testcase tc, SubmissionMessage msg, Long judge0Id, - ProblemInfo problemInfo, SubmissionContext context, SseEmitter emitter + int seqId, Testcase tc, SubmissionMessage msg, + Long judge0Id, ProblemInfo problemInfo, SubmissionContext context ) { CompletableFuture.runAsync(() -> { try { @@ -168,11 +175,13 @@ private void runTestcaseAsync( AnswerEvaluation evaluation = submissionDomainService.handleEvaluationAndUpdateStats( TestcaseEvaluationInput.from(tc, result), problemInfo, context ); - emitter.send(JudgeResultResponse.fromEvaluation(result, evaluation)); + + submissionEventService.publishTestcaseUpdate( + new TestcaseEvaluatedEvent(msg.sessionKey(), TestcaseResultPayload.fromEvaluation(seqId, result, evaluation)) + ); } catch (Exception e) { if (context.notified().compareAndSet(false, true)) { - emitter.completeWithError(e); - emitterStore.remove(msg.emitterKey()); + submissionEventService.publishSubmissionError(new SubmissionErrorEvent(msg.sessionKey(), e)); exceptionNotificationHelper(e); } } finally { diff --git a/src/main/java/org/ezcode/codetest/domain/submission/dto/AnswerEvaluation.java b/src/main/java/org/ezcode/codetest/domain/submission/dto/AnswerEvaluation.java index 169b14ee..fee2d754 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/dto/AnswerEvaluation.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/dto/AnswerEvaluation.java @@ -9,11 +9,7 @@ public record AnswerEvaluation( boolean timeEfficient, - boolean memoryEfficient, - - String expectedOutput, - - String actualOutput + boolean memoryEfficient ) { public boolean isPassed() { diff --git a/src/main/java/org/ezcode/codetest/domain/submission/dto/SubmissionData.java b/src/main/java/org/ezcode/codetest/domain/submission/dto/SubmissionData.java index d51831db..4cb8ae5f 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/dto/SubmissionData.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/dto/SubmissionData.java @@ -27,7 +27,7 @@ public record SubmissionData( String message, - double executionTime, + long executionTime, long memoryUsage diff --git a/src/main/java/org/ezcode/codetest/domain/submission/dto/SubmitProcessResult.java b/src/main/java/org/ezcode/codetest/domain/submission/dto/SubmitProcessResult.java deleted file mode 100644 index 60636dc9..00000000 --- a/src/main/java/org/ezcode/codetest/domain/submission/dto/SubmitProcessResult.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.ezcode.codetest.domain.submission.dto; - -import lombok.Builder; - -@Builder -public record SubmitProcessResult( - - boolean isCorrect, - - String expectedOutput, - - String actualOutput, - - double executionTime, - - long memoryUsage, - - String message - -) { - public static SubmitProcessResult of(SubmissionData submissionData, AnswerEvaluation evaluation) { - return SubmitProcessResult.builder() - .isCorrect(evaluation.isCorrect()) - .expectedOutput(evaluation.expectedOutput()) - .actualOutput(evaluation.actualOutput()) - .executionTime(submissionData.executionTime()) - .memoryUsage(submissionData.memoryUsage()) - .message(submissionData.message()) - .build(); - } -} diff --git a/src/main/java/org/ezcode/codetest/domain/submission/exception/code/SubmissionExceptionCode.java b/src/main/java/org/ezcode/codetest/domain/submission/exception/code/SubmissionExceptionCode.java index 72923348..50bf94b1 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/exception/code/SubmissionExceptionCode.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/exception/code/SubmissionExceptionCode.java @@ -17,6 +17,7 @@ public enum SubmissionExceptionCode implements ResponseCode { TESTCASE_TIMEOUT(false, HttpStatus.GATEWAY_TIMEOUT, "테스트케이스 채점 시간이 초과되었습니다."), REDIS_SERVER_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "Redis 서버 연결 실패"), ALREADY_JUDGING(false, HttpStatus.CONFLICT, "이미 해당 문제에 대한 채점이 진행 중입니다."), + UNKNOWN_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 예외가 발생했습니다."), ; private final boolean success; private final HttpStatus status; diff --git a/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionAggregator.java b/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionAggregator.java index a7633d36..71375578 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionAggregator.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionAggregator.java @@ -2,20 +2,20 @@ public class SubmissionAggregator { - private double totalExecutionTime; + private long totalExecutionTime; private long totalMemoryUsage; private int count; - public void accumulate(double executionTime, long memoryUsage) { + public void accumulate(long executionTime, long memoryUsage) { totalExecutionTime += executionTime; totalMemoryUsage += memoryUsage; count++; } - public double averageExecutionTime() { - return count == 0 ? 0.0 : Math.round(totalExecutionTime / count * 1000.0) / 1000.0; + public long averageExecutionTime() { + return count == 0 ? 0L : totalExecutionTime / count; } public long averageMemoryUsage() { diff --git a/src/main/java/org/ezcode/codetest/domain/submission/model/TestcaseEvaluationInput.java b/src/main/java/org/ezcode/codetest/domain/submission/model/TestcaseEvaluationInput.java index 8de981df..f661f171 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/model/TestcaseEvaluationInput.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/model/TestcaseEvaluationInput.java @@ -13,7 +13,7 @@ public record TestcaseEvaluationInput( boolean success, - double executionTime, + long executionTime, long memoryUsage diff --git a/src/main/java/org/ezcode/codetest/domain/submission/model/entity/Submission.java b/src/main/java/org/ezcode/codetest/domain/submission/model/entity/Submission.java index 509f0bab..3817985c 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/model/entity/Submission.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/model/entity/Submission.java @@ -51,14 +51,14 @@ public class Submission extends BaseEntity { private int testCaseTotalCount; @Column(name = "execution_time", nullable = false) - private Double executionTime; + private Long executionTime; @Column(name = "memory_usage", nullable = false) private Long memoryUsage; @Builder public Submission(User user, Problem problem, Language language, String code, String message, - int testCasePassedCount, int testCaseTotalCount, Double executionTime, Long memoryUsage) { + int testCasePassedCount, int testCaseTotalCount, Long executionTime, Long memoryUsage) { this.user = user; this.problem = problem; this.language = language; @@ -78,10 +78,6 @@ public Long getProblemId() { return this.problem.getId(); } - public String getProblemDescription() { - return this.problem.getDescription(); - } - public boolean isCorrect() { return this.testCasePassedCount == this.testCaseTotalCount; } diff --git a/src/main/java/org/ezcode/codetest/domain/submission/repository/UserProblemResultRepository.java b/src/main/java/org/ezcode/codetest/domain/submission/repository/UserProblemResultRepository.java index ada21a06..8eb06c5f 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/repository/UserProblemResultRepository.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/repository/UserProblemResultRepository.java @@ -12,9 +12,9 @@ public interface UserProblemResultRepository { Optional findUserProblemResultByUserIdAndProblemId(Long userId, Long problemId); - void saveUserProblemResult(UserProblemResult userProblemResult); + UserProblemResult saveUserProblemResult(UserProblemResult userProblemResult); - void updateUserProblemResult(UserProblemResult userProblemResult, boolean isCorrect); + UserProblemResult updateUserProblemResult(UserProblemResult userProblemResult, boolean isCorrect); @Query(""" SELECT upr.user.id, SUM(p.score) diff --git a/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java b/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java index 66df757b..ee85cf92 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java @@ -28,7 +28,10 @@ public class SubmissionDomainService { private final UserProblemResultRepository userProblemResultRepository; @Transactional - public void finalizeSubmission(SubmissionData submissionData, SubmissionAggregator aggregator, int passedCount) { + public UserProblemResult finalizeSubmission( + SubmissionData submissionData, + SubmissionAggregator aggregator, + int passedCount) { createSubmission(SubmissionData.toEntity( submissionData.withAggregatedStats(aggregator), @@ -38,20 +41,21 @@ public void finalizeSubmission(SubmissionData submissionData, SubmissionAggregat boolean allPassed = passedCount == submissionData.getTestCaseSize(); - getUserProblemResult(submissionData.getUserId(), submissionData.getProblemId()).ifPresentOrElse( - result -> { - if (!result.isCorrect()) { - modifyUserProblemResult(result, allPassed); - } - }, - () -> createUserProblemResult( + return getUserProblemResult(submissionData.getUserId(), submissionData.getProblemId()).map( + result -> { + if (!result.isCorrect() && allPassed) { + modifyUserProblemResult(result, true); + return result; + } + return result; + }) + .orElseGet(() -> createUserProblemResult( UserProblemResult.builder() .user(submissionData.user()) .problem(submissionData.problem()) .isCorrect(allPassed) .build() - ) - ); + )); } public AnswerEvaluation handleEvaluationAndUpdateStats( @@ -84,16 +88,16 @@ public List getWeeklySolveCounts( } private AnswerEvaluation evaluate( - String expectedOutput, String actualOutput, boolean success, double executionTime, long memoryUsage, + String expectedOutput, String actualOutput, boolean success, long executionTime, long memoryUsage, ProblemInfo problemInfo ) { boolean isCorrect = success && expectedOutput.strip().equals(actualOutput.strip()); boolean timeEfficient = executionTime <= problemInfo.getTimeLimit(); boolean memoryEfficient = memoryUsage <= problemInfo.getMemoryLimit(); - return new AnswerEvaluation(isCorrect, timeEfficient, memoryEfficient, expectedOutput, actualOutput); + return new AnswerEvaluation(isCorrect, timeEfficient, memoryEfficient); } - private void collectStatistics(SubmissionAggregator aggregator, double executionTime, long memoryUsage) { + private void collectStatistics(SubmissionAggregator aggregator, long executionTime, long memoryUsage) { aggregator.accumulate(executionTime, memoryUsage); } @@ -105,11 +109,11 @@ private Optional getUserProblemResult(Long userId, Long probl return userProblemResultRepository.findUserProblemResultByUserIdAndProblemId(userId, problemId); } - private void createUserProblemResult(UserProblemResult userProblemResult) { - userProblemResultRepository.saveUserProblemResult(userProblemResult); + private UserProblemResult createUserProblemResult(UserProblemResult userProblemResult) { + return userProblemResultRepository.saveUserProblemResult(userProblemResult); } - private void modifyUserProblemResult(UserProblemResult userProblemResult, boolean isCorrect) { - userProblemResultRepository.updateUserProblemResult(userProblemResult, isCorrect); + private UserProblemResult modifyUserProblemResult(UserProblemResult userProblemResult, boolean isCorrect) { + return userProblemResultRepository.updateUserProblemResult(userProblemResult, isCorrect); } } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java index d8e97e39..791097d6 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java @@ -62,7 +62,7 @@ public void initConsumerGroup() { log.info("Redis Stream 'judge-queue'에 대한 Consumer Group 'judge-group'을 생성했습니다."); } catch (Exception e) { - log.error("예외 발생: {}, 메시지: {}", e.getClass(), e.getMessage()); + log.info("예외 발생: {}, 메시지: {}", e.getClass(), e.getMessage()); if (e.getCause() instanceof io.lettuce.core.RedisBusyException) { log.info("Redis Consumer Group 'judge-group'이 이미 존재하여 생성을 건너뜁니다."); } else { diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/SubmissionMessage.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/SubmissionMessage.java similarity index 58% rename from src/main/java/org/ezcode/codetest/infrastructure/event/dto/SubmissionMessage.java rename to src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/SubmissionMessage.java index b3224029..1a8af3a4 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/SubmissionMessage.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/SubmissionMessage.java @@ -1,8 +1,8 @@ -package org.ezcode.codetest.infrastructure.event.dto; +package org.ezcode.codetest.infrastructure.event.dto.submission; public record SubmissionMessage( - String emitterKey, + String sessionKey, Long problemId, diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/ErrorWsResponse.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/ErrorWsResponse.java new file mode 100644 index 00000000..94f2bfeb --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/ErrorWsResponse.java @@ -0,0 +1,23 @@ +package org.ezcode.codetest.infrastructure.event.dto.submission.response; + +import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "WebSocket 채점 에러 응답") +public record ErrorWsResponse( + + @Schema(description = "에러 메시지", example = "테스트케이스 채점 시간이 초과되었습니다.") + String message, + + @Schema(description = "에러 코드", example = "TESTCASE_TIMEOUT") + String errorCode, + + @Schema(description = "HTTP 상태 코드", example = "504") + int status + +) { + public static ErrorWsResponse from(SubmissionExceptionCode code) { + return new ErrorWsResponse(code.getMessage(), code.name(), code.getStatus().value()); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/InitTestcaseListResponse.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/InitTestcaseListResponse.java new file mode 100644 index 00000000..a2279e33 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/InitTestcaseListResponse.java @@ -0,0 +1,32 @@ +package org.ezcode.codetest.infrastructure.event.dto.submission.response; + +import java.util.List; + +import org.ezcode.codetest.application.submission.dto.event.payload.InitTestcaseListPayload; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "테스트케이스 초기화 WebSocket 응답 DTO") +public record InitTestcaseListResponse( + + @Schema(description = "테스트케이스 순번 (1부터 시작)", example = "1") + int seqId, + + @Schema(description = "테스트케이스 입력 값", example = "1 2 3") + String input, + + @Schema(description = "예상 출력 값", example = "6") + String expectedOutput, + + @Schema(description = "채점 상태", example = "채점 중") + String status + +) { + public static InitTestcaseListResponse from(InitTestcaseListPayload payload) { + return new InitTestcaseListResponse(payload.seqId(), payload.input(), payload.expectedOutput(), payload.status()); + } + + public static List mapToList(List payload) { + return payload.stream().map(InitTestcaseListResponse::from).toList(); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/JudgeResultResponse.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/JudgeResultResponse.java new file mode 100644 index 00000000..dd08d207 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/JudgeResultResponse.java @@ -0,0 +1,41 @@ +package org.ezcode.codetest.infrastructure.event.dto.submission.response; + +import org.ezcode.codetest.application.submission.dto.event.payload.TestcaseResultPayload; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "각 테스트케이스에 대한 채점 결과") +public record JudgeResultResponse( + + @Schema(description = "테스트케이스 번호", example = "1") + int seqId, + + @Schema(description = "테스트케이스 통과 여부", example = "true") + boolean isPassed, + + @Schema(description = "실제 출력값", example = "12") + String actualOutput, + + @Schema(description = "실행 시간 (ms)", example = "129") + Long executionTime, + + @Schema(description = "메모리 사용량 (KB)", example = "12196") + Long memoryUsage, + + @Schema(description = "결과 메시지", example = "Accepted") + String message + +) { + public static JudgeResultResponse from(TestcaseResultPayload payload) { + return new JudgeResultResponse( + payload.seqId(), + payload.isPassed(), + payload.actualOutput(), + payload.executionTime(), + payload.memoryUsage(), + payload.message() + ); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/SubmissionFinalResultResponse.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/SubmissionFinalResultResponse.java new file mode 100644 index 00000000..983b37d3 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/response/SubmissionFinalResultResponse.java @@ -0,0 +1,30 @@ +package org.ezcode.codetest.infrastructure.event.dto.submission.response; + +import org.ezcode.codetest.application.submission.dto.event.payload.SubmissionFinalResultPayload; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "최종 채점 결과 응답 DTO") +public record SubmissionFinalResultResponse( + + @Schema(description = "전체 테스트케이스 수", example = "5") + int totalCount, + + @Schema(description = "통과한 테스트케이스 수", example = "5") + int passedCount, + + @Schema(description = "전체 통과 여부", example = "true") + boolean isCorrect, + + @Schema(description = "메시지", example = "Accepted") + String message + +) { + public static SubmissionFinalResultResponse from(SubmissionFinalResultPayload payload) { + return new SubmissionFinalResultResponse( + payload.getTotalCount(), + payload.getPassedCount(), + payload.isCorrect(), + payload.getMessage()); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/listener/RedisJudgeQueueConsumer.java b/src/main/java/org/ezcode/codetest/infrastructure/event/listener/RedisJudgeQueueConsumer.java index 1e3b45af..dba370c0 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/listener/RedisJudgeQueueConsumer.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/listener/RedisJudgeQueueConsumer.java @@ -5,7 +5,7 @@ import org.ezcode.codetest.application.submission.service.SubmissionService; import org.ezcode.codetest.domain.submission.exception.SubmissionException; import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; -import org.ezcode.codetest.infrastructure.event.dto.SubmissionMessage; +import org.ezcode.codetest.infrastructure.event.dto.submission.SubmissionMessage; import org.springframework.data.redis.connection.stream.MapRecord; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.stream.StreamListener; @@ -27,7 +27,7 @@ public void onMessage(MapRecord message) { Map values = message.getValue(); SubmissionMessage msg = new SubmissionMessage( - values.get("emitterKey"), + values.get("sessionKey"), Long.valueOf(values.get("problemId")), Long.valueOf(values.get("languageId")), Long.valueOf(values.get("userId")), @@ -35,7 +35,7 @@ public void onMessage(MapRecord message) { ); try { - log.info("[컨슈머 수신] {}", msg.emitterKey()); + log.info("[컨슈머 수신] {}", msg.sessionKey()); submissionService.submitCodeStream(msg); log.info("[컨슈머 ACK] messageId={}", message.getId()); diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/listener/SubmissionEventListener.java b/src/main/java/org/ezcode/codetest/infrastructure/event/listener/SubmissionEventListener.java new file mode 100644 index 00000000..649042d2 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/listener/SubmissionEventListener.java @@ -0,0 +1,48 @@ +package org.ezcode.codetest.infrastructure.event.listener; + +import java.util.List; + +import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; +import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseEvaluatedEvent; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.ErrorWsResponse; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.SubmissionFinalResultResponse; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.InitTestcaseListResponse; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.JudgeResultResponse; +import org.ezcode.codetest.infrastructure.event.publisher.StompMessageService; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class SubmissionEventListener { + + private final StompMessageService messageService; + + @EventListener + public void onTestcaseInit(TestcaseListInitializedEvent event) { + List wsDtos = InitTestcaseListResponse.mapToList(event.payload()); + messageService.sendInitTestcases(event.sessionKey(), wsDtos); + } + + @EventListener + public void onTestcaseUpdate(TestcaseEvaluatedEvent event) { + JudgeResultResponse wsDto = JudgeResultResponse.from(event.payload()); + messageService.sendTestcaseResultUpdate(event.sessionKey(), wsDto); + } + + @EventListener + public void onSubmissionFinished(SubmissionJudgingFinishedEvent event) { + SubmissionFinalResultResponse wsDto = SubmissionFinalResultResponse.from(event.payload()); + messageService.sendFinalResult(event.sessionKey(), wsDto); + } + + @EventListener + public void onSubmissionError(SubmissionErrorEvent event) { + ErrorWsResponse wsDto = ErrorWsResponse.from(event.code()); + messageService.sendError(event.sessionKey(), wsDto); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/ProblemEventPublisher.java b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/ProblemEventPublisher.java index 094c7c59..dc313010 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/ProblemEventPublisher.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/ProblemEventPublisher.java @@ -1,5 +1,6 @@ package org.ezcode.codetest.infrastructure.event.publisher; +import org.ezcode.codetest.application.submission.port.ProblemEventService; import org.ezcode.codetest.domain.submission.model.entity.UserProblemResult; import org.ezcode.codetest.infrastructure.event.dto.GameLevelUpEvent; import org.springframework.context.ApplicationEventPublisher; @@ -9,7 +10,7 @@ @Component @RequiredArgsConstructor -public class ProblemEventPublisher { +public class ProblemEventPublisher implements ProblemEventService { private final ApplicationEventPublisher publisher; diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/RedisJudgeQueueProducer.java b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/RedisJudgeQueueProducer.java index 0743efd4..1d4e557a 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/RedisJudgeQueueProducer.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/RedisJudgeQueueProducer.java @@ -3,7 +3,7 @@ import java.util.Map; import org.ezcode.codetest.application.submission.port.QueueProducer; -import org.ezcode.codetest.infrastructure.event.dto.SubmissionMessage; +import org.ezcode.codetest.infrastructure.event.dto.submission.SubmissionMessage; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @@ -18,9 +18,9 @@ public class RedisJudgeQueueProducer implements QueueProducer { private final RedisTemplate redisTemplate; public void enqueue(SubmissionMessage submissionMessage) { - log.info("[SSE enqueue] emitterKey: {}", submissionMessage.emitterKey()); + log.info("[SSE enqueue] emitterKey: {}", submissionMessage.sessionKey()); Map map = Map.of( - "emitterKey", submissionMessage.emitterKey(), + "sessionKey", submissionMessage.sessionKey(), "problemId", submissionMessage.problemId().toString(), "languageId", submissionMessage.languageId().toString(), "userId", submissionMessage.userId().toString(), diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java index 06131072..7d813ede 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java @@ -1,5 +1,9 @@ package org.ezcode.codetest.infrastructure.event.publisher; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.ErrorWsResponse; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.SubmissionFinalResultResponse; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.InitTestcaseListResponse; +import org.ezcode.codetest.infrastructure.event.dto.submission.response.JudgeResultResponse; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessageType; @@ -68,6 +72,38 @@ public void handleNotificationList(List dataList, String p ); } + public void sendInitTestcases(String sessionKey, List dataList) { + + messagingTemplate.convertAndSend( + "/topic/submission/" + sessionKey + "/init", + dataList + ); + } + + public void sendTestcaseResultUpdate(String sessionKey, JudgeResultResponse data) { + + messagingTemplate.convertAndSend( + "/topic/submission/" + sessionKey + "/case", + data + ); + } + + public void sendFinalResult(String sessionKey, SubmissionFinalResultResponse data) { + + messagingTemplate.convertAndSend( + "/topic/submission/" + sessionKey + "/final", + data + ); + } + + public void sendError(String sessionKey, ErrorWsResponse data) { + + messagingTemplate.convertAndSend( + "/topic/submission/" + sessionKey + "/error", + data + ); + } + public void handleChatMessageBroadcast(T data, Long roomId) { messagingTemplate.convertAndSend("/topic/chat/" + roomId, data); diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/SubmissionEventPublisher.java b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/SubmissionEventPublisher.java new file mode 100644 index 00000000..d0261358 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/SubmissionEventPublisher.java @@ -0,0 +1,39 @@ +package org.ezcode.codetest.infrastructure.event.publisher; + +import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; +import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; +import org.ezcode.codetest.application.submission.dto.event.TestcaseEvaluatedEvent; +import org.ezcode.codetest.application.submission.port.SubmissionEventService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class SubmissionEventPublisher implements SubmissionEventService { + + private final ApplicationEventPublisher publisher; + + @Override + public void publishInitTestcases(TestcaseListInitializedEvent event) { + publisher.publishEvent(event); + } + + @Override + public void publishTestcaseUpdate(TestcaseEvaluatedEvent event) { + publisher.publishEvent(event); + } + + @Override + public void publishFinalResult(SubmissionJudgingFinishedEvent event) { + publisher.publishEvent(event); + } + + @Override + public void publishSubmissionError(SubmissionErrorEvent event) { + publisher.publishEvent(event); + } + +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0ResponseMapper.java b/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0ResponseMapper.java index cb64f448..7e067c7b 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0ResponseMapper.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0ResponseMapper.java @@ -12,7 +12,7 @@ public JudgeResult toJudgeResult(ExecutionResultResponse executionResultResponse boolean success = isSuccessful(executionResultResponse); return JudgeResult.builder() .actualOutput(output) - .executionTime(executionResultResponse.getTime()) + .executionTime(toMilliseconds(executionResultResponse.getTime())) .memoryUsage(executionResultResponse.getMemory()) .success(success) .message(executionResultResponse.status().description()) @@ -20,16 +20,32 @@ public JudgeResult toJudgeResult(ExecutionResultResponse executionResultResponse } private String extractActualOutput(ExecutionResultResponse executionResultResponse) { - if (executionResultResponse.stdout() != null) - return executionResultResponse.stdout(); - if (executionResultResponse.compile_output() != null) + if (executionResultResponse.stdout() != null) { + return normalizeOutput(executionResultResponse.stdout()); + } + + if (executionResultResponse.compile_output() != null) { return executionResultResponse.compile_output(); - if (executionResultResponse.stderr() != null) + } + + if (executionResultResponse.stderr() != null) { return executionResultResponse.stderr(); + } + return "(No output)"; } private boolean isSuccessful(ExecutionResultResponse executionResultResponse) { return executionResultResponse.stdout() != null && executionResultResponse.status().id() == 3; } + + private long toMilliseconds(double timeInSeconds) { + return Math.round(timeInSeconds * 1000); + } + + private String normalizeOutput(String output) { + return output + .replaceAll("[\\r\\n]+$", "") + .strip(); + } } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/openai/OpenAIMessageBuilder.java b/src/main/java/org/ezcode/codetest/infrastructure/openai/OpenAIMessageBuilder.java index 7f736c68..debe6658 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/openai/OpenAIMessageBuilder.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/openai/OpenAIMessageBuilder.java @@ -14,6 +14,7 @@ class OpenAIMessageBuilder { private static final String PREFIX = """ 당신은 코딩 테스트 사이트의 코드 리뷰어입니다. 아래 **정확히** 이 형식을 지켜 응답하세요: + '시간 복잡도:', '코드 총평:' 같은 제목은 **제목**과 같이 볼드체로 변환합니다. """.stripIndent(); private static final String SUFFIX = """ @@ -62,25 +63,25 @@ private String buildSystemPrompt(boolean isCorrect) { String body; if (isCorrect) { body = """ - <정답일 경우> - 시간 복잡도: Big-O 표기법으로만 답하세요. **단, N과 M을 같다고 가정하고 n으로 표기하세요.** - - 코드에 포함된 중첩 루프(depth)에 따라 O(N^k) 형태로 정확히 표기해주세요. + 코드에 포함된 중첩 루프(depth)에 따라 O(N^k) 형태로 정확히 표기해주세요. **for 루프뿐만 아니라 while 루프도 모두 중첩(depth)에 포함**하여, 코드에 실제로 있는 루프 개수만큼 exponent를 세십시오. 예) for-for-for ⇒ O(n³), for-for-while ⇒ O(n³), for-for-for-for-while ⇒ O(n⁵) - + \n - 코드 총평: - 각 문장은 한 탭(\t) 들여쓰기 + '- ' 로 시작. + 각 문장은 한 탭(\t) 들여쓰기 + '- '로 시작. 문장 끝에만 마침표를 붙이세요. + \n - 조금 더 개선할 수 있는 방안: - 각 문장은 한 탭(\t) 들여쓰기 + '- ' 로 시작. + 각 문장은 한 탭(\t) 들여쓰기 + '- '로 시작. 문장 끝에만 마침표를 붙이세요. """.stripIndent(); } else { body = """ - <오답일 경우> - 코드 총평: - 각 문장은 한 탭(\t) 들여쓰기 + '- ' 로 시작. + - 코드 총평: + 각 문장은 한 탭(\t) 들여쓰기 + '- '로 시작. 문장 끝에만 마침표를 붙이세요. + \n - 공부하면 좋은 키워드: 1. 첫 번째 키워드 2. 두 번째 키워드 diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/impl/UserProblemResultRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/impl/UserProblemResultRepositoryImpl.java index 9d27d3fe..4afd820a 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/impl/UserProblemResultRepositoryImpl.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/impl/UserProblemResultRepositoryImpl.java @@ -23,13 +23,14 @@ public Optional findUserProblemResultByUserIdAndProblemId(Lon } @Override - public void saveUserProblemResult(UserProblemResult userProblemResult) { - userProblemResultJpaRepository.save(userProblemResult); + public UserProblemResult saveUserProblemResult(UserProblemResult userProblemResult) { + return userProblemResultJpaRepository.save(userProblemResult); } @Override - public void updateUserProblemResult(UserProblemResult userProblemResult, boolean isCorrect) { + public UserProblemResult updateUserProblemResult(UserProblemResult userProblemResult, boolean isCorrect) { userProblemResult.updateResult(isCorrect); + return userProblemResult; } @Override diff --git a/src/main/java/org/ezcode/codetest/infrastructure/sse/InMemoryEmitterStore.java b/src/main/java/org/ezcode/codetest/infrastructure/sse/InMemoryEmitterStore.java deleted file mode 100644 index 6713413b..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/sse/InMemoryEmitterStore.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.ezcode.codetest.infrastructure.sse; - -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -import org.ezcode.codetest.application.submission.port.EmitterStore; -import org.ezcode.codetest.domain.submission.exception.SubmissionException; -import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -public class InMemoryEmitterStore implements EmitterStore { - - private final Map emitterMap = new ConcurrentHashMap<>(); - - @Override - public void saveWithCallbacks(String key, SseEmitter emitter) { - emitterMap.put(key, emitter); - - emitter.onCompletion(() -> log.info("[SSE 완료] 정상 종료됨")); - - emitter.onTimeout(() -> { - log.warn("[SSE 타임아웃] 연결 시간이 초과되었습니다"); - emitter.completeWithError(new SubmissionException(SubmissionExceptionCode.EMITTER_SEND_ERROR)); - remove(key); - }); - - emitter.onError(e -> { - log.error("[SSE 에러 발생] 예외: {}", e.toString(), e); - remove(key); - }); - - } - - @Override - public Optional get(String key) { - return Optional.ofNullable(emitterMap.get(key)); - } - - @Override - public SseEmitter getOrElseThrow(String key) { - return Optional.ofNullable(emitterMap.get(key)) - .orElseThrow(() -> new SubmissionException(SubmissionExceptionCode.EMITTER_NOT_FOUND)); - } - - @Override - public void remove(String key) { - emitterMap.remove(key); - } -} diff --git a/src/main/java/org/ezcode/codetest/presentation/submission/SubmissionController.java b/src/main/java/org/ezcode/codetest/presentation/submission/SubmissionController.java index cbcc3e3d..2d2751ee 100644 --- a/src/main/java/org/ezcode/codetest/presentation/submission/SubmissionController.java +++ b/src/main/java/org/ezcode/codetest/presentation/submission/SubmissionController.java @@ -1,7 +1,10 @@ package org.ezcode.codetest.presentation.submission; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import org.ezcode.codetest.application.submission.dto.request.review.CodeReviewRequest; @@ -18,7 +21,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -33,36 +38,35 @@ public class SubmissionController { private final SubmissionService submissionService; - @PostMapping("/problems/{problemId}/submit-stream") + @PostMapping("/problems/{problemId}/submit-ws") @Operation( - summary = "코드 제출 (SSE 응답)", + summary = "코드 제출 (WebSocket)", description = """ - 이 API는 Server-Sent Events(SSE)를 통해 테스트케이스별 채점 결과를 스트리밍으로 전송합니다. - - 응답 MIME 타입: `text/event-stream` - - 응답 예시: - ``` - data: {"isPassed":true,"expectedOutput":"7","actualOutput":"7","executionTime":0.129,"memoryUsage":12196,"message":"Accepted"} - ``` - - ``` - event: final - data: {"totalCount":5,"passedCount":5,"message":"Accepted", correct":true} - ``` - """ - ) - @ApiResponse( - responseCode = "200", - description = "SSE로 스트리밍 응답 전송", - content = @Content(mediaType = "text/event-stream") + 문제에 대한 코드를 제출하면 채점 큐에 등록되고, + 서버는 WebSocket(STOMP)을 통해 채점 결과를 실시간으로 전송합니다. + + 반환된 sessionKey를 사용해 다음 경로로 구독하십시오: + • /topic/submission/{sessionKey}/init + • /topic/submission/{sessionKey}/case + • /topic/submission/{sessionKey}/final + • /topic/submission/{sessionKey}/error + """ ) - public SseEmitter submitCodeStream( + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "코드 제출 성공 및 sessionKey 반환"), + @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 데이터"), + @ApiResponse(responseCode = "409", description = "이미 해당 문제를 채점 중인 경우"), + }) + public ResponseEntity> submitCodeStream( @Parameter(description = "제출할 문제 ID", required = true) @PathVariable Long problemId, @RequestBody @Valid CodeSubmitRequest request, @AuthenticationPrincipal AuthUser authUser ) { - return submissionService.enqueueCodeSubmission(problemId, request, authUser); + HashSet set = new HashSet<>(); + set.add(submissionService.enqueueCodeSubmission(problemId, request, authUser)); + return ResponseEntity + .status(HttpStatus.OK) + .body(set); } @Operation( diff --git a/src/main/resources/templates/submit-test.html b/src/main/resources/templates/submit-test.html index 9deaea46..f22e71b4 100644 --- a/src/main/resources/templates/submit-test.html +++ b/src/main/resources/templates/submit-test.html @@ -1,165 +1,125 @@ - - 코드 제출 테스트 + + 코드 제출 테스트 (WebSocket) + +
-

소스코드 제출

- +

코드 제출 (WebSocket)

- - - - - - - - + - - - - - - + + + + + +
-

채점 결과

-
-

코드 리뷰 결과

-
-
-