diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/request/submission/CodeSubmitRequest.java b/src/main/java/org/ezcode/codetest/application/submission/dto/request/submission/CodeSubmitRequest.java index 2342c8b6..38cce0bd 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/dto/request/submission/CodeSubmitRequest.java +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/request/submission/CodeSubmitRequest.java @@ -16,7 +16,10 @@ public record CodeSubmitRequest( example = "public class Main { public static void main(String[] args) { System.out.println(\"Hello World\"); } }" ) @NotBlank(message = "소스 코드는 필수 입력 값입니다.") - String sourceCode + String sourceCode, + + @NotBlank(message = "세션 키는 필수 입력 값입니다.") + String sessionKey ) { } 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 0286e47a..72526eb6 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 @@ -55,8 +55,7 @@ public class SubmissionService { private final JudgementService judgementService; private final GitHubPushService gitHubPushService; - public SubmitResponse enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) { - + public SubmitResponse prepareSubmission(Long problemId, AuthUser authUser) { boolean acquired = lockManager.tryLock("submission", authUser.getId(), problemId); if (!acquired) { throw new SubmissionException(SubmissionExceptionCode.ALREADY_JUDGING); @@ -65,11 +64,13 @@ public SubmitResponse enqueueCodeSubmission(Long problemId, CodeSubmitRequest re String sessionKey = authUser.getId() + "_" + UUID.randomUUID(); List testcaseList = testcaseDomainService.getTestcaseListByProblemId(problemId); + return SubmitResponse.of(sessionKey, testcaseList); + } + + public void enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) { queueProducer.enqueue( - new SubmissionMessage(sessionKey, problemId, request.languageId(), authUser.getId(), request.sourceCode()) + SubmissionMessage.of(request, problemId, authUser.getId()) ); - - return SubmitResponse.of(sessionKey, testcaseList); } @Async("judgeSubmissionExecutor") diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/SubmissionMessage.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/SubmissionMessage.java index 1a8af3a4..f9ff0dad 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/SubmissionMessage.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/submission/SubmissionMessage.java @@ -1,5 +1,7 @@ package org.ezcode.codetest.infrastructure.event.dto.submission; +import org.ezcode.codetest.application.submission.dto.request.submission.CodeSubmitRequest; + public record SubmissionMessage( String sessionKey, @@ -13,4 +15,13 @@ public record SubmissionMessage( String sourceCode ) { + public static SubmissionMessage of(CodeSubmitRequest request, Long problemId, Long userId) { + return new SubmissionMessage( + request.sessionKey(), + problemId, + request.languageId(), + userId, + request.sourceCode() + ); + } } 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 f14531d0..aebe4664 100644 --- a/src/main/java/org/ezcode/codetest/presentation/submission/SubmissionController.java +++ b/src/main/java/org/ezcode/codetest/presentation/submission/SubmissionController.java @@ -32,37 +32,46 @@ public class SubmissionController { private final SubmissionService submissionService; - @PostMapping("/problems/{problemId}/submit-ws") + @PostMapping("/problems/{problemId}/submit-prepare") + public ResponseEntity prepareSubmission( + @PathVariable Long problemId, + @AuthenticationPrincipal AuthUser authUser + ) { + return ResponseEntity + .status(HttpStatus.OK) + .body(submissionService.prepareSubmission(problemId, authUser)); + } + + @PostMapping("/problems/{problemId}/submit-ready") @Operation( summary = "코드 제출 (WebSocket)", description = """ - 문제에 대한 코드를 제출하면 채점 큐에 등록되고, - 서버는 WebSocket(STOMP)을 통해 채점 결과를 실시간으로 전송합니다. - - 반환된 sessionKey를 사용해 다음 경로로 구독하세요. - - • /user/queue/submission/{sessionKey}/case - - • /user/queue/submission/{sessionKey}/final - - • /topic/submission/{sessionKey}/error - - • /user/queue/submission/{sessionKey}/git-status - """ + 문제에 대한 코드를 제출하면 채점 큐에 등록되고, + 서버는 WebSocket(STOMP)을 통해 채점 결과를 실시간으로 전송합니다. + + 반환된 sessionKey를 사용해 다음 경로로 구독하세요. + + • /user/queue/submission/{sessionKey}/case + + • /user/queue/submission/{sessionKey}/final + + • /topic/submission/{sessionKey}/error + + • /user/queue/submission/{sessionKey}/git-status + """ ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "코드 제출 성공 및 sessionKey 반환"), @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 데이터"), @ApiResponse(responseCode = "409", description = "이미 해당 문제를 채점 중인 경우"), }) - public ResponseEntity submitCodeStream( + public ResponseEntity submitCodeStream( @Parameter(description = "제출할 문제 ID", required = true) @PathVariable Long problemId, @RequestBody @Valid CodeSubmitRequest request, @AuthenticationPrincipal AuthUser authUser ) { - return ResponseEntity - .status(HttpStatus.OK) - .body(submissionService.enqueueCodeSubmission(problemId, request, authUser)); + submissionService.enqueueCodeSubmission(problemId, request, authUser); + return ResponseEntity.status(HttpStatus.OK).build(); } @Operation( diff --git a/src/main/resources/templates/test-submit.html b/src/main/resources/templates/test-submit.html index 665181fa..d66e9c9c 100644 --- a/src/main/resources/templates/test-submit.html +++ b/src/main/resources/templates/test-submit.html @@ -953,7 +953,6 @@

채점 결과

const pid = window.problemId; const languageId = +document.getElementById('language').value; const sourceCode = window.getEditorCode(); - const payload = { languageId, sourceCode }; const judgeEl = document.getElementById('judgeResult'); judgeEl.innerHTML = ''; reviewBox.style.display = 'none'; @@ -961,24 +960,38 @@

채점 결과

finalIsCorrect = null; try { - const res = await fetch(`/api/problems/${pid}/submit-ws`, { + const res = await fetch(`/api/problems/${pid}/submit-prepare`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...tokenHeader() }, - body: JSON.stringify(payload) }); const resp = await res.json(); console.log('[DEBUG] 전체 응답:', resp); console.log('[DEBUG] result:', resp.result); if (!resp.success) { - judgeEl.textContent = `Error: ${resp.message}`; + judgeEl.textContent = `${resp.message}`; return; } const { sessionKey, testcaseIds } = resp.result; - initWebSocket(sessionKey); - renderInitSlots(testcaseIds); + + initWebSocket(sessionKey, async () => { + renderInitSlots(testcaseIds); + + await fetch(`/api/problems/${pid}/submit-ready`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...tokenHeader() + }, + body: JSON.stringify({ + sessionKey, + languageId, + sourceCode + }) + }); + }); } catch (err) { console.error(err); judgeEl.textContent = '채점 요청 실패'; @@ -1058,7 +1071,7 @@

채점 결과

// submitCode() 에서 호출할 함수 window.getEditorCode = () => editor.getValue(); - function initWebSocket(sessionKey) { + function initWebSocket(sessionKey, onReady) { const tokenOnly = (sessionStorage.getItem('accessToken') || '').replace(/^Bearer /, ''); const socket = new SockJS(`/ws?token=${encodeURIComponent(tokenOnly)}`); stompClient = Stomp.over(socket); @@ -1082,7 +1095,13 @@

채점 결과

else if (status === 'FAILED') gitDot.style.backgroundColor = '#ff4d4d'; else gitDot.style.backgroundColor = 'gray'; }); - }, err => console.error('[STOMP] 연결 오류:', err)); + + if (typeof onReady === 'function') { + onReady(); + } + }, err => { + console.error('[STOMP] 연결 오류:', err); + }); } function handleCase(data) { @@ -1431,7 +1450,7 @@

채점 결과

if (res.ok) { // 성공: 목록 새로고침 if (item.classList.contains('discussion-item')) { - loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best'); + await loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best'); } else { const discussionId = item.closest('.discussion-item').dataset.discussionId; const container = document.getElementById(`replies-for-${discussionId}`); @@ -1457,7 +1476,7 @@

채점 결과

const discussionId = item.closest('.discussion-item').dataset.discussionId; url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies/${e.target.dataset.id}`; } - (async () => { + await (async () => { const res = await fetch(url, { method, headers: { @@ -1466,7 +1485,7 @@

채점 결과

}); if (res.ok) { if (item.classList.contains('discussion-item')) { - loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best'); + await loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best'); } else { const discussionId = item.closest('.discussion-item').dataset.discussionId; const container = document.getElementById(`replies-for-${discussionId}`); @@ -1493,7 +1512,7 @@

채점 결과

body: JSON.stringify({ voteType }) }); if (res.ok) { - updateDiscussionVoteUI(discussionId); + await updateDiscussionVoteUI(discussionId); } else { alert('추천/비추천에 실패했습니다.'); } @@ -1534,7 +1553,7 @@

채점 결과

body: JSON.stringify({ voteType }) }); if (res.ok) { - updateReplyVoteUI(discussionId, replyId); + await updateReplyVoteUI(discussionId, replyId); } else { alert('추천/비추천에 실패했습니다.'); } diff --git a/src/test/java/org/ezcode/codetest/application/submission/SubmissionServiceTest.java b/src/test/java/org/ezcode/codetest/application/submission/SubmissionServiceTest.java index d8f29005..1dbb7eb6 100644 --- a/src/test/java/org/ezcode/codetest/application/submission/SubmissionServiceTest.java +++ b/src/test/java/org/ezcode/codetest/application/submission/SubmissionServiceTest.java @@ -26,6 +26,7 @@ import org.ezcode.codetest.domain.language.service.LanguageDomainService; import org.ezcode.codetest.domain.problem.model.ProblemInfo; import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.problem.model.entity.Testcase; import org.ezcode.codetest.domain.problem.service.ProblemDomainService; import org.ezcode.codetest.domain.problem.service.TestcaseDomainService; import org.ezcode.codetest.domain.submission.exception.SubmissionException; @@ -125,28 +126,44 @@ void setup() { class SuccessCases { @Test - @DisplayName("락 획득 성공 -> 메세지 큐에 전송하고 sessionKey 반환") + @DisplayName("락 획득 성공 -> sessionKey, testcaseList 반환") + void prepareSubmissionSuccess() { + + // given + given(authUser.getId()).willReturn(userId); + given(lockManager.tryLock("submission", userId, problemId)).willReturn(true); + + List testcaseList = List.of(mock(Testcase.class), mock(Testcase.class)); + given(testcaseDomainService.getTestcaseListByProblemId(problemId)).willReturn(testcaseList); + + // when + SubmitResponse result = submissionService.prepareSubmission(problemId, authUser); + + // then + assertThat(result.sessionKey()).startsWith(userId + "_"); + assertThat(result.testcaseIds()).hasSize(2); + then(lockManager).should().tryLock("submission", userId, problemId); + then(testcaseDomainService).should().getTestcaseListByProblemId(problemId); + } + + @Test + @DisplayName("큐에 메세지 전송 확인") void enqueueCodeSubmissionSucceeds() { // given given(authUser.getId()).willReturn(userId); given(request.languageId()).willReturn(languageId); given(request.sourceCode()).willReturn(sourceCode); - given(lockManager.tryLock("submission", authUser.getId(), problemId)) - .willReturn(true); - given(testcaseDomainService.getTestcaseListByProblemId(problemId)).willReturn(List.of()); // when - SubmitResponse result = submissionService.enqueueCodeSubmission(problemId, request, authUser); + submissionService.enqueueCodeSubmission(problemId, request, authUser); then(queueProducer).should() .enqueue(argThat(msg -> - msg.sessionKey().startsWith(sessionKey) && msg.problemId().equals(problemId) && msg.languageId().equals(languageId) && msg.sourceCode().equals(sourceCode) )); - assertThat(result.sessionKey()).startsWith(sessionKey); } @Test @@ -251,7 +268,7 @@ class FailureCases { @Test @DisplayName("락 획득 실패 -> ALREADY_JUDGING 예외") - void enqueueCodeSubmissionFailed() { + void prepareSubmissionFailed() { // given given(authUser.getId()).willReturn(userId); @@ -260,7 +277,7 @@ void enqueueCodeSubmissionFailed() { // when & then assertThatThrownBy(() -> - submissionService.enqueueCodeSubmission(problemId, request, authUser) + submissionService.prepareSubmission(problemId, authUser) ) .isInstanceOf(SubmissionException.class) .hasMessage(SubmissionExceptionCode.ALREADY_JUDGING.getMessage());