diff --git a/.gitignore b/.gitignore index 937c6c6b..d5a8cee9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ out/ ### VS Code ### .vscode/ + + +.gradle-home +*.md \ No newline at end of file diff --git a/build.gradle b/build.gradle index 74dc8a87..b6cc7b4d 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,9 @@ version = '0.0.1-SNAPSHOT' java { sourceCompatibility = '17' + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { diff --git a/docs/virtual-workspace-schema.sql b/docs/virtual-workspace-schema.sql new file mode 100644 index 00000000..803a39c6 --- /dev/null +++ b/docs/virtual-workspace-schema.sql @@ -0,0 +1,33 @@ +-- VirtualWorkspace 단일-row 갱신 모델 스키마 +-- - PK: (player_id, stage_code) +-- - UNIQUE: public_id (Gateway lookup key) + +CREATE TABLE virtual_workspace ( + player_id BIGINT NOT NULL, + stage_code VARCHAR(32) NOT NULL, + + public_id VARCHAR(63) NOT NULL, + lifecycle_status VARCHAR(20) NOT NULL, + + owner_subject_id VARCHAR(255) NOT NULL, + access_mode VARCHAR(20) NOT NULL, + + base_host VARCHAR(255) NOT NULL, + + k8s_namespace VARCHAR(63) NULL, + service_name VARCHAR(63) NULL, + service_port INT NULL, + + image VARCHAR(255) NOT NULL, + port INT NOT NULL, + cpu VARCHAR(32) NOT NULL, + memory VARCHAR(32) NOT NULL, + readiness_path VARCHAR(128) NOT NULL, + + created_date DATETIME(0) NULL, + modified_date DATETIME(0) NULL, + + PRIMARY KEY (player_id, stage_code), + UNIQUE KEY uk_virtual_workspace_public_id (public_id) +); + diff --git a/src/main/java/org/codequistify/master/domain/lab/controller/LabController.java b/src/main/java/org/codequistify/master/domain/lab/controller/LabController.java deleted file mode 100644 index 9f240503..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/controller/LabController.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.codequistify.master.domain.lab.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.codequistify.master.domain.lab.dto.PShellCreateResponse; -import org.codequistify.master.domain.lab.dto.PShellExistsResponse; -import org.codequistify.master.domain.lab.service.LabService; -import org.codequistify.master.domain.player.domain.Player; -import org.codequistify.master.domain.stage.domain.Stage; -import org.codequistify.master.domain.stage.service.impl.StageSearchServiceImpl; -import org.codequistify.master.global.aspect.LogExecutionTime; -import org.codequistify.master.global.aspect.LogMonitoring; -import org.codequistify.master.global.lock.LockManager; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.concurrent.locks.ReentrantLock; - -@RestController -@RequiredArgsConstructor -@Tag(name = "Lab") -public class LabController { - private final LabService labService; - private final StageSearchServiceImpl stageSearchService; - private final LockManager lockManager; - - private final String LAB_HOST = "wss://lab.pol.or.kr"; - - private final Logger LOGGER = LoggerFactory.getLogger(LabController.class); - - @Operation( - summary = "가상 터미널 (PShell) 생성요청", - description = """ - :stage에 대한 터미널(PShell)을 생성한다. - 기존 터미널이 존재할경우, 제거하고 생성한다. - 생성시에 네트워크 연결까지 약 10초 정도가 소요되며, - 제거시에는 약 45s 이상이 소요된다. - - 사용시에는 쿼리파라미터 값을 추가해서 connection 연결을 보내야한다. - """ - ) - @LogMonitoring - @PostMapping("lab/terminal/stage/{stage_id}") - public ResponseEntity applyPShell(@AuthenticationPrincipal Player player, - @PathVariable(name = "stage_id") Long stageId) { - ReentrantLock lock = lockManager.getLock(player.getId(), stageId); - if (lock.tryLock()) { - try { - Stage stage = stageSearchService.getStageById(stageId); - - labService.deleteSyncStageOnKubernetes(player, stage); // 동기 삭제 - labService.createStageOnKubernetes(player, stage); - - PShellCreateResponse response = PShellCreateResponse - .of(LAB_HOST, player.getUid(), stage.getStageImage().name().toLowerCase()); - - labService.waitForPodReadiness(player, stage); - - return ResponseEntity - .status(HttpStatus.OK) - .body(response); - } finally { - lockManager.unlock(player.getId(), stageId); - } - } - - // 락 걸린 동안 들어오는 요청은 무시 - LOGGER.info("[applyPShell] apply 작업중 중복된 요청 발생. stage: {}", stageId); - return ResponseEntity - .status(HttpStatus.TOO_MANY_REQUESTS) - .body(null); - - } - - // 접속 가능한 주소 조회 - @Operation( - summary = "가상 터미널 (PShell) 접속 주소 & 쿼리파라미터 조회", - description = """ - :stage에 대한 터미널(PShell)에 접속할 수 있는 쿼리파라미터 정보를 조회한다. - - 사용시에는 쿼리파라미터 값을 추가해서 connection 연결을 보내야한다. - """ - ) - @GetMapping("lab/terminal/access-url/{stage_id}") - @LogMonitoring - public ResponseEntity getPShellAccessUrl(@AuthenticationPrincipal Player player, - @PathVariable(name = "stage_id") Long stageId) { - Stage stage = stageSearchService.getStageById(stageId); - - PShellCreateResponse response = PShellCreateResponse - .of(LAB_HOST, player.getUid(), stage.getStageImage().name().toLowerCase()); - - return ResponseEntity - .status(HttpStatus.OK) - .body(response); - } - - - // 현재 터미널 존재 여부 조회 - @Operation( - summary = "기존 가상 터미널 (PShell) 존재여부 조회", - description = """ - :stage에 대한 기존 터미널(PShell)이 존재하는지를 확인한다. - """ - ) - @LogExecutionTime - @GetMapping("/lab/terminal/existence/{stage_id}") - public ResponseEntity checkPShellExistence(@AuthenticationPrincipal Player player, - @PathVariable(name = "stage_id") Long stageId) { - Stage stage = stageSearchService.getStageById(stageId); - - boolean stageExists = labService.existsStageOnKubernetes(player, stage); - - PShellExistsResponse response = new PShellExistsResponse( - player.getUid(), - stageId, - stage.getStageImage().name(), - stageExists - ); - - return ResponseEntity - .status(HttpStatus.OK) - .body(response); - } -} diff --git a/src/main/java/org/codequistify/master/domain/lab/dto/PShellCreateRequest.java b/src/main/java/org/codequistify/master/domain/lab/dto/PShellCreateRequest.java deleted file mode 100644 index 050219d9..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/dto/PShellCreateRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.codequistify.master.domain.lab.dto; - -public record PShellCreateRequest() { -} diff --git a/src/main/java/org/codequistify/master/domain/lab/dto/PShellCreateResponse.java b/src/main/java/org/codequistify/master/domain/lab/dto/PShellCreateResponse.java deleted file mode 100644 index b6ce0c98..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/dto/PShellCreateResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.codequistify.master.domain.lab.dto; - -public record PShellCreateResponse( - String url, - String query -) { - public static PShellCreateResponse of (String url, String uid, String stageImageName) { - String query = "?uid=" + uid.toLowerCase() + "&stage=" + stageImageName; - return new PShellCreateResponse( - url+query, - query - ); - } - - public static record XHeader ( - String key, - String value - ){} -} diff --git a/src/main/java/org/codequistify/master/domain/lab/dto/PShellExistsResponse.java b/src/main/java/org/codequistify/master/domain/lab/dto/PShellExistsResponse.java deleted file mode 100644 index 52842914..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/dto/PShellExistsResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.codequistify.master.domain.lab.dto; - -public record PShellExistsResponse( - String uid, - Long stageId, - String stageCode, - Boolean exists -) { -} diff --git a/src/main/java/org/codequistify/master/domain/lab/factory/KubernetesPodFactory.java b/src/main/java/org/codequistify/master/domain/lab/factory/KubernetesPodFactory.java deleted file mode 100644 index f9a59dea..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/factory/KubernetesPodFactory.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.codequistify.master.domain.lab.factory; - -import io.fabric8.kubernetes.api.model.IntOrString; -import io.fabric8.kubernetes.api.model.Pod; -import io.fabric8.kubernetes.api.model.PodBuilder; -import org.codequistify.master.domain.lab.utils.KubernetesResourceNaming; -import org.codequistify.master.domain.stage.domain.Stage; -import org.codequistify.master.domain.stage.domain.StageImageType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -@Component -public class KubernetesPodFactory implements PodFactory { - private final Logger LOGGER = LoggerFactory.getLogger(KubernetesPodFactory.class); - private final static Long ACTIVE_DEADLINE = 10_800L; - - @Override - public Pod create(Stage stage, int port, String uid) { - StageImageType stageImage = stage.getStageImage(); - String podName = KubernetesResourceNaming.getPodName(stageImage.name(), uid); - - return new PodBuilder() - .withNewMetadata() - .withName(podName) - .addToLabels("app", "pol") - .addToLabels("tire", "term") - .addToLabels("player", uid) - .addToLabels("stage", stageImage.name().toLowerCase()) - .endMetadata() - .withNewSpec() - .addNewContainer() - .withName(stageImage.name().toLowerCase()) - .withImage(stageImage.getImageName()) - .addNewPort() - .withContainerPort(port) - .endPort() - .withNewReadinessProbe()// agent 준비 확인 - .withNewHttpGet() - .withPath("/health") - .withPort(new IntOrString(8080)) - .endHttpGet() - .withInitialDelaySeconds(9) - .withPeriodSeconds(2) - .endReadinessProbe() - .endContainer() - .withActiveDeadlineSeconds(ACTIVE_DEADLINE) - .endSpec().build(); - } - - private String generatePodName(StageImageType stageImage, String uid) { - return stageImage.name().toLowerCase() + "-pod-" + uid; - } -} diff --git a/src/main/java/org/codequistify/master/domain/lab/factory/KubernetesServiceFactory.java b/src/main/java/org/codequistify/master/domain/lab/factory/KubernetesServiceFactory.java deleted file mode 100644 index efe710db..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/factory/KubernetesServiceFactory.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.codequistify.master.domain.lab.factory; - -import io.fabric8.kubernetes.api.model.IntOrString; -import io.fabric8.kubernetes.api.model.Service; -import io.fabric8.kubernetes.api.model.ServiceBuilder; -import org.codequistify.master.domain.lab.utils.KubernetesResourceNaming; -import org.codequistify.master.domain.stage.domain.Stage; -import org.codequistify.master.domain.stage.domain.StageImageType; -import org.springframework.stereotype.Component; - -@Component -public class KubernetesServiceFactory implements ServiceFactory{ - private final static Long ACTIVE_DEADLINE = 10_800L; - @Override - public Service create(Stage stage, int port, String uid) { - StageImageType stageImage = stage.getStageImage(); - String serviceName = KubernetesResourceNaming.getServiceName(stageImage.name(), uid); - - return new ServiceBuilder() - .withNewMetadata() - .withName(serviceName) - .addToLabels("app", "pol") - .addToLabels("tire", "term") - .addToLabels("player", uid) - .addToLabels("stage", stageImage.name().toLowerCase()) - .endMetadata() - .withNewSpec() - .withType("ClusterIP") - .addNewPort() - .withName("http") - .withProtocol("TCP") - .withPort(port) - .withTargetPort(new IntOrString(port)) - .endPort() - .addToSelector("app", "pol") - .addToSelector("tire", "term") - .addToSelector("player", uid) - .addToSelector("stage", stageImage.name().toLowerCase()) - .endSpec().build(); - } - - private String generateServiceName(StageImageType stageImage, String uid) { - return stageImage.name().toLowerCase() + "-svc-" + uid; - } -} diff --git a/src/main/java/org/codequistify/master/domain/lab/factory/PodFactory.java b/src/main/java/org/codequistify/master/domain/lab/factory/PodFactory.java deleted file mode 100644 index a531e696..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/factory/PodFactory.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.codequistify.master.domain.lab.factory; - -import io.fabric8.kubernetes.api.model.Pod; -import org.codequistify.master.domain.stage.domain.Stage; - -public interface PodFactory { - Pod create(Stage stage, int port, String uid); -} diff --git a/src/main/java/org/codequistify/master/domain/lab/factory/ServiceFactory.java b/src/main/java/org/codequistify/master/domain/lab/factory/ServiceFactory.java deleted file mode 100644 index a4b46be8..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/factory/ServiceFactory.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.codequistify.master.domain.lab.factory; - -import io.fabric8.kubernetes.api.model.Service; -import org.codequistify.master.domain.stage.domain.Stage; - -public interface ServiceFactory { - Service create(Stage stage, int port, String uid); -} diff --git a/src/main/java/org/codequistify/master/domain/lab/service/KubernetesResourceCollector.java b/src/main/java/org/codequistify/master/domain/lab/service/KubernetesResourceCollector.java deleted file mode 100644 index 8d8bab00..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/service/KubernetesResourceCollector.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.codequistify.master.domain.lab.service; - -import io.fabric8.kubernetes.api.model.Pod; -import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Bean; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class KubernetesResourceCollector { - private final KubernetesResourceManager kubernetesResourceManager; - private final Logger LOGGER = LoggerFactory.getLogger(KubernetesResourceCollector.class); - - @Scheduled(cron = "0 0 * * * ?") - public void resourceCollection() { - List timeoutPods = extractPodNames(kubernetesResourceManager.getTimeOutPods()); - timeoutPods.forEach(timeoutPod -> { - kubernetesResourceManager.deleteAsyncPod(timeoutPod); - kubernetesResourceManager.deleteAsyncService(timeoutPod); - }); - - LOGGER.info("[resourceCollection] PShell {}개 제거, List: {}", timeoutPods.size(), timeoutPods.toString()); - } - - private String getResourceName(Pod pod) { - return pod.getMetadata().getName(); - } - - private List extractPodNames(List pods) { - return pods.stream() - .map(this::getResourceName) - .collect(Collectors.toList()); - } - -} diff --git a/src/main/java/org/codequistify/master/domain/lab/service/KubernetesResourceManager.java b/src/main/java/org/codequistify/master/domain/lab/service/KubernetesResourceManager.java deleted file mode 100644 index 64695a2d..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/service/KubernetesResourceManager.java +++ /dev/null @@ -1,165 +0,0 @@ -package org.codequistify.master.domain.lab.service; - -import io.fabric8.kubernetes.api.model.Pod; -import io.fabric8.kubernetes.api.model.PodList; -import io.fabric8.kubernetes.api.model.Service; -import io.fabric8.kubernetes.api.model.StatusDetails; -import io.fabric8.kubernetes.client.KubernetesClient; -import lombok.RequiredArgsConstructor; -import org.codequistify.master.domain.lab.factory.PodFactory; -import org.codequistify.master.domain.lab.factory.ServiceFactory; -import org.codequistify.master.domain.lab.utils.KubernetesResourceNaming; -import org.codequistify.master.domain.stage.domain.Stage; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Bean; - -import java.util.List; - -@org.springframework.stereotype.Service -@RequiredArgsConstructor -public class KubernetesResourceManager { - private final Logger LOGGER = LoggerFactory.getLogger(KubernetesResourceManager.class); - - private final PodFactory podFactory; - private final ServiceFactory serviceFactory; - - private final KubernetesClient kubernetesClient; - - public Service createServiceOnKubernetes(Stage stage, String uid) { - Service service = serviceFactory.create(stage, 8080, uid); - - service = kubernetesClient.services() - .inNamespace("default") - .resource(service) - .create(); - - LOGGER.debug("[createServiceOnKubernetes] service: {}", service.getMetadata().getName()); - return service; - } - - public Pod createPodOnKubernetes(Stage stage, String uid) { - Pod pod = podFactory.create(stage, 8080, uid); - - pod = kubernetesClient.pods() - .inNamespace("default") - .resource(pod) - .create(); - - LOGGER.debug("[createPodOnKubernetes] pod: {}", pod.getMetadata().getName()); - return pod; - } - - public void deleteAsyncService(Stage stage, String uid) { - String svcName = KubernetesResourceNaming.getServiceName(stage.getStageImage().name(), uid); - - List result = kubernetesClient.services() - .inNamespace("default") - .withName(svcName) - .delete(); - - LOGGER.debug("[deleteAsyncService] result {}", result); - } - - public void deleteAsyncService(String svcName) { - List result = kubernetesClient.services() - .inNamespace("default") - .withName(svcName) - .delete(); - - LOGGER.debug("[deleteAsyncService] result {}", result); - } - - public void deleteAsyncPod(Stage stage, String uid) { - String podName = KubernetesResourceNaming.getPodName(stage.getStageImage().name(), uid); - - List result = kubernetesClient.pods() - .inNamespace("default") - .withName(podName) - .delete(); - - LOGGER.debug("[deleteAsyncPod] result {}", result); - } - - public void deleteAsyncPod(String podName) { - List result = kubernetesClient.pods() - .inNamespace("default") - .withName(podName) - .delete(); - - LOGGER.debug("[deleteAsyncPod] result {}", result); - } - - public Service getService(Stage stage, String uid) { - String svcName = KubernetesResourceNaming.getServiceName(stage.getStageImage().name(), uid); - - Service service = kubernetesClient.services() - .inNamespace("default") - .withName(svcName) - .get(); - - LOGGER.debug("[getService] name : {}", service.getMetadata().getName()); - return service; - } - - public Pod getPod(Stage stage, String uid) { - String podName = KubernetesResourceNaming.getPodName(stage.getStageImage().name(), uid); - - Pod pod = kubernetesClient.pods() - .inNamespace("default") - .withName(podName) - .get(); - - LOGGER.debug("[getPod] name : {}", pod.getMetadata().getName()); - return pod; - } - - public boolean existsService(Stage stage, String uid) { - String svcName = KubernetesResourceNaming.getServiceName(stage.getStageImage().name(), uid); - - boolean exists = kubernetesClient.services() - .inNamespace("default") - .withName(svcName) - .get() != null; - - //LOGGER.info("[existsService] name: {}, exists: {}", svcName, exists); - return exists; - } - - public boolean existsPod(Stage stage, String uid) { - String podName = KubernetesResourceNaming.getPodName(stage.getStageImage().name(), uid); - - boolean exists = kubernetesClient.pods() - .inNamespace("default") - .withName(podName) - .get() != null; - - //LOGGER.info("[existsPod] name: {}, exists: {}", podName, exists); - return exists; - } - - public List getTimeOutPods() { - PodList podList = kubernetesClient.pods().inNamespace("default").list(); - - return podList.getItems().stream() - .filter(this::isPhaseFailed) - .filter(this::isReasonDeadlineExceeded) - .filter(this::hasErrorStatus) - .toList(); - } - - private boolean isPhaseFailed(Pod pod) { - return "Failed".equals(pod.getStatus().getPhase()); - } - - private boolean isReasonDeadlineExceeded(Pod pod) { - return "DeadlineExceeded".equals(pod.getStatus().getReason()); - } - - private boolean hasErrorStatus(Pod pod) { - return pod.getStatus().getContainerStatuses().stream() - .anyMatch(status -> status.getState().getTerminated() != null && - "Error".equals(status.getState().getTerminated().getReason())); - } - -} diff --git a/src/main/java/org/codequistify/master/domain/lab/service/LabAssignmentService.java b/src/main/java/org/codequistify/master/domain/lab/service/LabAssignmentService.java deleted file mode 100644 index be1f9d97..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/service/LabAssignmentService.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.codequistify.master.domain.lab.service; - -import lombok.RequiredArgsConstructor; -import org.codequistify.master.domain.lab.utils.KubernetesResourceNaming; -import org.codequistify.master.domain.stage.domain.StageImageType; -import org.codequistify.master.domain.stage.dto.StageActionRequest; -import org.codequistify.master.global.aspect.LogExecutionTime; -import org.codequistify.master.global.exception.ErrorCode; -import org.codequistify.master.global.exception.domain.BusinessException; -import org.codequistify.master.global.util.SuccessResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Bean; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpServerErrorException; -import org.springframework.web.client.ResourceAccessException; -import org.springframework.web.client.RestTemplate; - -@RequiredArgsConstructor -@Service -public class LabAssignmentService { - private final RestTemplate restTemplate; - private final Logger LOGGER = LoggerFactory.getLogger(LabAssignmentService.class); - private final String NAMESPACE = "default"; - - - @Bean - public void testA() { - String stageCode = StageImageType.S1015.name(); - String uid = "pol-bdbeej-gj5antzprz"; - //String qUrl = KubernetesResourceNaming.getQuery(stageCode, uid); - System.out.println("https://lab.pol.or.kr/grade"+KubernetesResourceNaming.getQuery(stageCode, uid)); - System.out.println("https://lab.pol.or.kr/compose"+KubernetesResourceNaming.getQuery(stageCode, uid)); - } - - @LogExecutionTime - public ResponseEntity sendGradingRequest(String stageCode, String uid, StageActionRequest request) { - //String svcName = KubernetesResourceNaming.getServiceName(stageCode, uid); - String url = "https://lab.pol.or.kr/grade" + KubernetesResourceNaming.getQuery(stageCode, uid); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - request = new StageActionRequest(request.stageCode().toLowerCase(), request.questionIndex()); - - HttpEntity entity = new HttpEntity<>(request, headers); - - // URL 및 요청 데이터 로깅 - LOGGER.info("Request URL: {}", url); - LOGGER.info("Request Data: {}", request); - - try { - ResponseEntity response = restTemplate.postForEntity(url, entity, SuccessResponse.class); - if (response.getStatusCode().is5xxServerError()) { - LOGGER.info("[sendGradingRequest] 실습서버가 정상적으로 응답하지 않습니다. url: {}", url); - throw new BusinessException(ErrorCode.FAIL_PROCEED, HttpStatus.INTERNAL_SERVER_ERROR); - } - return response; - } catch (HttpServerErrorException e) { - // 서버 오류에 대한 상세 정보 로깅 - LOGGER.error("[sendGradingRequest] Internal Server Error: {}, URL: {}", e.getResponseBodyAsString(), url); - throw e; - } catch (ResourceAccessException e) { - // 리소스 접근 오류에 대한 상세 정보 로깅 - LOGGER.error("[sendGradingRequest] Resource Access Error: {}, URL: {}", e.getMessage(), url); - throw e; - } - } - - @LogExecutionTime - public ResponseEntity sendComposeRequest(String stageCode, String uid, StageActionRequest request) { - String url = "https://lab.pol.or.kr/compose"+KubernetesResourceNaming.getQuery(stageCode, uid); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - request = new StageActionRequest(request.stageCode().toLowerCase(), request.questionIndex()); - LOGGER.info("qurl: {}", url); - HttpEntity entity = new HttpEntity<>(request, headers); - - ResponseEntity response = restTemplate.postForEntity(url, entity, SuccessResponse.class); - if (response.getStatusCode().is5xxServerError()) { - LOGGER.info("[sendComposeRequest] 실습서버가 정상적으로 응답하지 않습니다. url: {}", url); - throw new BusinessException(ErrorCode.FAIL_PROCEED, HttpStatus.INTERNAL_SERVER_ERROR); - } - return response; - } -} diff --git a/src/main/java/org/codequistify/master/domain/lab/service/LabService.java b/src/main/java/org/codequistify/master/domain/lab/service/LabService.java deleted file mode 100644 index 0761b077..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/service/LabService.java +++ /dev/null @@ -1,116 +0,0 @@ -package org.codequistify.master.domain.lab.service; - -import io.fabric8.kubernetes.api.model.Pod; -import io.fabric8.kubernetes.api.model.PodCondition; -import lombok.RequiredArgsConstructor; -import org.codequistify.master.domain.player.domain.Player; -import org.codequistify.master.domain.stage.domain.Stage; -import org.codequistify.master.global.aspect.LogExecutionTime; -import org.codequistify.master.global.exception.ErrorCode; -import org.codequistify.master.global.exception.domain.BusinessException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; - -@org.springframework.stereotype.Service -@RequiredArgsConstructor -public class LabService { - private final KubernetesResourceManager kubernetesResourceManager; - private final Logger LOGGER = LoggerFactory.getLogger(LabService.class); - private final static int THRESHOLD = 20; - private final static int SLEEP_PERIOD = 5000; - - @LogExecutionTime - public void createStageOnKubernetes(Player player, Stage stage){ - String uid = player.getUid().toLowerCase(); - - kubernetesResourceManager.createServiceOnKubernetes(stage, uid); - kubernetesResourceManager.createPodOnKubernetes(stage, uid); - - LOGGER.info("[createStageOnKubernetes] stage: {}", stage.getId()); - - } - - @LogExecutionTime - public void deleteAsyncStageOnKubernetes(Player player, Stage stage) { - String uid = player.getUid().toLowerCase(); - - kubernetesResourceManager.deleteAsyncPod(stage, uid); - kubernetesResourceManager.deleteAsyncService(stage, uid); - } - - @LogExecutionTime - public void deleteSyncStageOnKubernetes(Player player, Stage stage) { - String uid = player.getUid().toLowerCase(); - - kubernetesResourceManager.deleteAsyncPod(stage, uid); - kubernetesResourceManager.deleteAsyncService(stage, uid); - - boolean podDeleted = false; - boolean serviceDeleted = false; - int retryCount = 0; - - while (!podDeleted || !serviceDeleted) { - if (!podDeleted && !kubernetesResourceManager.existsPod(stage, uid)) { - podDeleted = true; - LOGGER.info("[deleteSyncStageOnKubernetes] Pod 삭제 확인 {}번 시도", retryCount); - } - if (!serviceDeleted && !kubernetesResourceManager.existsService(stage, uid)) { - serviceDeleted = true; - LOGGER.info("[deleteSyncStageOnKubernetes] Service 삭제 확인 {}번 시도", retryCount); - } - if (retryCount > THRESHOLD) { - LOGGER.error("[deleteSyncStageOnKubernetes] {}",ErrorCode.PSHELL_CREATE_FAILED.getMessage()); - throw new BusinessException(ErrorCode.PSHELL_CREATE_FAILED, HttpStatus.INTERNAL_SERVER_ERROR); - } - try { - Thread.sleep(SLEEP_PERIOD); - retryCount++; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOGGER.info("[deleteSyncStageOnKubernetes] 인터럽트 오류 발생"); - throw new BusinessException(ErrorCode.FAIL_PROCEED, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - } - - @LogExecutionTime - public boolean existsStageOnKubernetes(Player player, Stage stage) { - boolean podExists = kubernetesResourceManager.existsPod(stage, player.getUid()); - boolean serviceExists = kubernetesResourceManager.existsService(stage, player.getUid()); - - LOGGER.info("[existsStageOnKubernetes] pod: {}, svc: {}", podExists, serviceExists); - return podExists && serviceExists; - } - - @LogExecutionTime - public void waitForPodReadiness(Player player, Stage stage) { - String uid = player.getUid().toLowerCase(); - int retryCount = 0; - while (true) { - Pod pod = kubernetesResourceManager.getPod(stage, uid); - if (pod != null && pod.getStatus() != null && pod.getStatus().getConditions() != null) { - for (PodCondition condition : pod.getStatus().getConditions()) { - if ("Ready".equals(condition.getType()) && "True".equals(condition.getStatus())) { - LOGGER.info("[waitForPodReadiness] 네트워크 구성완료, pod: {}, time: {}ms", pod.getMetadata().getName(), retryCount * 2000); - return; - } - } - } - - if (retryCount > THRESHOLD) { - LOGGER.error("[checkPodReady] {}",ErrorCode.PSHELL_CREATE_FAILED.getMessage()); - throw new BusinessException(ErrorCode.PSHELL_CREATE_FAILED, HttpStatus.INTERNAL_SERVER_ERROR); - } - try { - Thread.sleep(2000L); - retryCount++; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOGGER.info("[checkPodReady] 인터럽트 오류 발생"); - throw new BusinessException(ErrorCode.FAIL_PROCEED, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - } - -} diff --git a/src/main/java/org/codequistify/master/domain/lab/utils/KubernetesResourceNaming.java b/src/main/java/org/codequistify/master/domain/lab/utils/KubernetesResourceNaming.java deleted file mode 100644 index fa97b7c6..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/utils/KubernetesResourceNaming.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.codequistify.master.domain.lab.utils; - -public class KubernetesResourceNaming { - //private static final String POD_NAME_FORMAT = "%s-pod-%s"; - private static final String POD_NAME_FORMAT = "%s-%s"; - //private static final String SERVICE_NAME_FORMAT = "%s-svc-%s"; - private static final String SERVICE_NAME_FORMAT = "%s-%s"; - private static final String SERVICE_DNS_FORMAT = "%s.%s.svc.cluster.local"; - - private static final String QUERY_URL_FORMAT = "?uid=%s&stage=%s"; - - public static String getPodName(String stageCode, String uid) { - return String.format(POD_NAME_FORMAT, stageCode.toLowerCase(), uid.toLowerCase()); - } - - public static String getServiceName(String stageCode, String uid) { - return String.format(SERVICE_NAME_FORMAT, stageCode.toLowerCase(), uid.toLowerCase()); - } - - public static String getServiceDNS(String svcName, String namespace) { - return String.format(SERVICE_DNS_FORMAT, svcName, namespace); - } - - public static String getQuery(String stageCode, String uid) { - return String.format(QUERY_URL_FORMAT, uid, stageCode.toLowerCase()); - } -} diff --git a/src/main/java/org/codequistify/master/domain/lab/vo/Label.java b/src/main/java/org/codequistify/master/domain/lab/vo/Label.java deleted file mode 100644 index f6efc434..00000000 --- a/src/main/java/org/codequistify/master/domain/lab/vo/Label.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.codequistify.master.domain.lab.vo; - -public record Label( - String key, - String value -) { -} diff --git a/src/main/java/org/codequistify/master/domain/player/domain/Player.java b/src/main/java/org/codequistify/master/domain/player/domain/Player.java index 64e6d82d..c910ebb8 100644 --- a/src/main/java/org/codequistify/master/domain/player/domain/Player.java +++ b/src/main/java/org/codequistify/master/domain/player/domain/Player.java @@ -33,6 +33,9 @@ public class Player extends BaseTimeEntity implements UserDetails { @Column(name = "player_id") private Long id; + @Transient + private PlayerId playerId; + @Column(name = "uid", unique = true) private String uid; // pol 고유 식별 번호 @@ -112,6 +115,13 @@ public void expireAccount() { this.locked = true; } + public PlayerId id() { + if (playerId == null && id != null) { + playerId = PlayerId.of(id); + } + return playerId; + } + public void dataClear() { this.name = null; this.email = null; @@ -174,6 +184,14 @@ protected void onPrePersist() { addRoles(List.of(PlayerRoleType.PLAYER), List.of(PlayerAccessType.BASIC_PROBLEMS_ACCESS)); } + @PostLoad + @PostPersist + private void syncPlayerId() { + if (id != null) { + this.playerId = PlayerId.of(id); + } + } + private String generateUID() { return UidGenerator.generate(this.email); } diff --git a/src/main/java/org/codequistify/master/domain/player/domain/PlayerId.java b/src/main/java/org/codequistify/master/domain/player/domain/PlayerId.java new file mode 100644 index 00000000..221b7089 --- /dev/null +++ b/src/main/java/org/codequistify/master/domain/player/domain/PlayerId.java @@ -0,0 +1,16 @@ +package org.codequistify.master.domain.player.domain; + +import jakarta.persistence.Embeddable; + +import java.util.Objects; + +@Embeddable +public record PlayerId(Long value) { + public PlayerId { + Objects.requireNonNull(value, "player id must not be null"); + } + + public static PlayerId of(Long value) { + return new PlayerId(value); + } +} diff --git a/src/main/java/org/codequistify/master/domain/player/service/PlayerDetailsService.java b/src/main/java/org/codequistify/master/domain/player/service/PlayerDetailsService.java index 6dc8d40f..21399f5d 100644 --- a/src/main/java/org/codequistify/master/domain/player/service/PlayerDetailsService.java +++ b/src/main/java/org/codequistify/master/domain/player/service/PlayerDetailsService.java @@ -54,7 +54,7 @@ public void resetPassword(Player player, UpdatePasswordRequest request) { @LogMonitoring public void updatePassword(Player player, UpdatePasswordRequest request) { // 비밀번호 데이터는 담기지 않으므로 다시 조회해야함 - player = playerRepository.getReferenceById(player.getId()); // jwt 필터에서 null 아닌 player 객체만 들어옴 + player = playerRepository.getReferenceById(player.id().value()); // jwt 필터에서 null 아닌 player 객체만 들어옴 if (!player.decodePassword(request.rawPassword(), passwordEncoder)) { throw new BusinessException(ErrorCode.INVALID_EMAIL_OR_PASSWORD, HttpStatus.BAD_REQUEST); diff --git a/src/main/java/org/codequistify/master/domain/player/service/PlayerProfileService.java b/src/main/java/org/codequistify/master/domain/player/service/PlayerProfileService.java index e58351b5..56721fda 100644 --- a/src/main/java/org/codequistify/master/domain/player/service/PlayerProfileService.java +++ b/src/main/java/org/codequistify/master/domain/player/service/PlayerProfileService.java @@ -34,19 +34,19 @@ public List findAllPlayerProfiles() { @LogExecutionTime @Transactional public PlayerStageProgressResponse getCompletedStagesByPlayerId(Player player) { - return stageSearchService.getCompletedStagesByPlayerId(player.getId()); + return stageSearchService.getCompletedStagesByPlayerId(player.id()); } @LogExecutionTime @Transactional public PlayerStageProgressResponse getInProgressStagesByPlayerId(Player player) { - return stageSearchService.getInProgressStagesByPlayerId(player.getId()); + return stageSearchService.getInProgressStagesByPlayerId(player.id()); } @LogExecutionTime @Transactional public List getHeatMapDataPointsByModifiedDate(Player player) { - return stageSearchService.getHeatMapDataPointsByModifiedDate(player.getId()); + return stageSearchService.getHeatMapDataPointsByModifiedDate(player.id()); } public boolean isAdmin(Player player) { diff --git a/src/main/java/org/codequistify/master/domain/shared/net/EndpointPath.java b/src/main/java/org/codequistify/master/domain/shared/net/EndpointPath.java new file mode 100644 index 00000000..82538e9e --- /dev/null +++ b/src/main/java/org/codequistify/master/domain/shared/net/EndpointPath.java @@ -0,0 +1,31 @@ +package org.codequistify.master.domain.shared.net; + +import java.util.Objects; + +public record EndpointPath(String value) { + public EndpointPath { + Objects.requireNonNull(value, "path must not be null"); + value = normalize(value); + validate(value); + } + + public static EndpointPath of(String value) { + return new EndpointPath(value); + } + + private static String normalize(String value) { + return value.trim(); + } + + private static void validate(String value) { + if (value.isBlank()) { + throw new IllegalArgumentException("path must not be blank"); + } + if (!value.startsWith("/")) { + throw new IllegalArgumentException("path must start with '/'"); + } + if (value.contains("://") || value.contains("?")) { + throw new IllegalArgumentException("path must be path only"); + } + } +} diff --git a/src/main/java/org/codequistify/master/domain/shared/net/ExternalHost.java b/src/main/java/org/codequistify/master/domain/shared/net/ExternalHost.java new file mode 100644 index 00000000..e5864349 --- /dev/null +++ b/src/main/java/org/codequistify/master/domain/shared/net/ExternalHost.java @@ -0,0 +1,32 @@ +package org.codequistify.master.domain.shared.net; + +import java.util.Locale; +import java.util.Objects; + +public record ExternalHost(String value) { + public ExternalHost { + Objects.requireNonNull(value, "host must not be null"); + value = normalize(value); + validate(value); + } + + public static ExternalHost of(String value) { + return new ExternalHost(value); + } + + private static String normalize(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + + private static void validate(String value) { + if (value.isBlank()) { + throw new IllegalArgumentException("host must not be blank"); + } + if (value.contains("://") || value.contains("/") || value.contains("?")) { + throw new IllegalArgumentException("host must be host only"); + } + if (value.startsWith(".") || value.endsWith(".")) { + throw new IllegalArgumentException("host must not start/end with '.'"); + } + } +} diff --git a/src/main/java/org/codequistify/master/domain/shared/net/UrlBuilder.java b/src/main/java/org/codequistify/master/domain/shared/net/UrlBuilder.java new file mode 100644 index 00000000..01c63c7d --- /dev/null +++ b/src/main/java/org/codequistify/master/domain/shared/net/UrlBuilder.java @@ -0,0 +1,24 @@ +package org.codequistify.master.domain.shared.net; + +import org.codequistify.master.global.data.UrlQuery; + +import java.util.Objects; + +public final class UrlBuilder { + private UrlBuilder() { + } + + public static String build(UrlScheme scheme, ExternalHost host) { + Objects.requireNonNull(scheme, "scheme must not be null"); + Objects.requireNonNull(host, "host must not be null"); + return scheme.value() + host.value(); + } + + public static String build(UrlScheme scheme, ExternalHost host, EndpointPath path, UrlQuery query) { + Objects.requireNonNull(scheme, "scheme must not be null"); + Objects.requireNonNull(host, "host must not be null"); + Objects.requireNonNull(path, "path must not be null"); + Objects.requireNonNull(query, "query must not be null"); + return scheme.value() + host.value() + path.value() + query.value(); + } +} diff --git a/src/main/java/org/codequistify/master/domain/shared/net/UrlScheme.java b/src/main/java/org/codequistify/master/domain/shared/net/UrlScheme.java new file mode 100644 index 00000000..bf059eec --- /dev/null +++ b/src/main/java/org/codequistify/master/domain/shared/net/UrlScheme.java @@ -0,0 +1,16 @@ +package org.codequistify.master.domain.shared.net; + +public enum UrlScheme { + HTTPS("https://"), + WSS("wss://"); + + private final String value; + + UrlScheme(String value) { + this.value = value; + } + + public String value() { + return value; + } +} diff --git a/src/main/java/org/codequistify/master/domain/shared/stage/StageCode.java b/src/main/java/org/codequistify/master/domain/shared/stage/StageCode.java new file mode 100644 index 00000000..4f9077dc --- /dev/null +++ b/src/main/java/org/codequistify/master/domain/shared/stage/StageCode.java @@ -0,0 +1,22 @@ +package org.codequistify.master.domain.shared.stage; + +import java.util.Locale; +import java.util.Objects; + +public record StageCode(String value) { + public StageCode { + Objects.requireNonNull(value, "stageCode must not be null"); + value = value.trim(); + if (value.isBlank()) { + throw new IllegalArgumentException("stageCode must not be blank"); + } + } + + public static StageCode from(String value) { + return new StageCode(value); + } + + public String lowercase() { + return value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/org/codequistify/master/domain/stage/controller/StageManagementController.java b/src/main/java/org/codequistify/master/domain/stage/controller/StageManagementController.java index 1936d2ff..9c43fd44 100644 --- a/src/main/java/org/codequistify/master/domain/stage/controller/StageManagementController.java +++ b/src/main/java/org/codequistify/master/domain/stage/controller/StageManagementController.java @@ -56,6 +56,7 @@ public ResponseEntity registryStage(@RequestBody StageRegistryReq @PostMapping("questions/grading") public ResponseEntity submitAnswerForGrading(@AuthenticationPrincipal Player player, @Valid @RequestBody GradingRequest request) { + validatePlayer(player, request); GradingResponse response = stageManagementService.evaluateAnswer(player, request); if (request.questionIndex() == 1) { // 첫문제인 경우 진행 시작 기록 @@ -78,7 +79,8 @@ public ResponseEntity submitAnswerForGrading(@AuthenticationPri @PostMapping("question/compose") public ResponseEntity compose(@AuthenticationPrincipal Player player, @Valid @RequestBody GradingRequest request) { - SuccessResponse successResponse = stageManagementService.composePShell(player, request); + validatePlayer(player, request); + SuccessResponse successResponse = stageManagementService.composeVirtualWorkspace(player, request); if (successResponse.success().equals(false)) { throw new BusinessException(ErrorCode.FAIL_PROCEED, HttpStatus.INTERNAL_SERVER_ERROR); @@ -114,5 +116,12 @@ public ResponseEntity completeStage(@AuthenticationPrin // 스테이지 옵션 수정 + // !Note 어노테이션으로 변경해야함 + private void validatePlayer(Player player, GradingRequest request) { + if (!player.getId().equals(request.playerId())) { + throw new BusinessException(ErrorCode.INSUFFICIENT_PLAYER_PERMISSION, HttpStatus.FORBIDDEN); + } + } + } diff --git a/src/main/java/org/codequistify/master/domain/stage/dto/GradingRequest.java b/src/main/java/org/codequistify/master/domain/stage/dto/GradingRequest.java index d4213acb..5b841695 100644 --- a/src/main/java/org/codequistify/master/domain/stage/dto/GradingRequest.java +++ b/src/main/java/org/codequistify/master/domain/stage/dto/GradingRequest.java @@ -1,9 +1,11 @@ package org.codequistify.master.domain.stage.dto; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record GradingRequest( - @NotNull(message = "4101") Long stageId, + @NotNull(message = "4101") Long playerId, + @NotBlank(message = "4101") String stageCode, @NotNull(message = "4101") Integer questionIndex, String answer ) { diff --git a/src/main/java/org/codequistify/master/domain/stage/dto/StageActionRequest.java b/src/main/java/org/codequistify/master/domain/stage/dto/StageActionRequest.java deleted file mode 100644 index 086b77ae..00000000 --- a/src/main/java/org/codequistify/master/domain/stage/dto/StageActionRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.codequistify.master.domain.stage.dto; - -public record StageActionRequest( - String stageCode, - Integer questionIndex -) { -} diff --git a/src/main/java/org/codequistify/master/domain/stage/repository/StageRepository.java b/src/main/java/org/codequistify/master/domain/stage/repository/StageRepository.java index e1e683aa..e9b00422 100644 --- a/src/main/java/org/codequistify/master/domain/stage/repository/StageRepository.java +++ b/src/main/java/org/codequistify/master/domain/stage/repository/StageRepository.java @@ -1,7 +1,10 @@ package org.codequistify.master.domain.stage.repository; +import java.util.Optional; + import org.codequistify.master.domain.stage.domain.Stage; import org.codequistify.master.domain.stage.domain.StageGroupType; +import org.codequistify.master.domain.stage.domain.StageImageType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,6 +14,8 @@ public interface StageRepository extends JpaRepository { Page findByStageGroup(StageGroupType stageGroup, Pageable pageable); + Optional findByStageImage(StageImageType stageImage); + /*@Query("SELECT new org.codequistify.master.domain.stage.dto." + "StageResponseTEMP(s.id, s.title, s.description, s.stageGroup, s.difficultyLevel, s.questionCount, " + "CASE WHEN c IS NULL THEN 'NOT_COMPLETED' ELSE c.status END) " + diff --git a/src/main/java/org/codequistify/master/domain/stage/service/StageManagementService.java b/src/main/java/org/codequistify/master/domain/stage/service/StageManagementService.java index 7157f709..87550175 100644 --- a/src/main/java/org/codequistify/master/domain/stage/service/StageManagementService.java +++ b/src/main/java/org/codequistify/master/domain/stage/service/StageManagementService.java @@ -15,7 +15,7 @@ public interface StageManagementService { GradingResponse evaluateAnswer(Player player, GradingRequest request); // 다음 문제 설정 구성 - SuccessResponse composePShell(Player player, GradingRequest request); + SuccessResponse composeVirtualWorkspace(Player player, GradingRequest request); // 풀이 완료 기록 StageCompletionResponse recordStageComplete(Player player, Long stageId); diff --git a/src/main/java/org/codequistify/master/domain/stage/service/StageSearchService.java b/src/main/java/org/codequistify/master/domain/stage/service/StageSearchService.java index 4dbc752d..447a1588 100644 --- a/src/main/java/org/codequistify/master/domain/stage/service/StageSearchService.java +++ b/src/main/java/org/codequistify/master/domain/stage/service/StageSearchService.java @@ -1,7 +1,9 @@ package org.codequistify.master.domain.stage.service; import org.codequistify.master.domain.player.domain.Player; +import org.codequistify.master.domain.player.domain.PlayerId; import org.codequistify.master.domain.player.dto.PlayerStageProgressResponse; +import org.codequistify.master.domain.shared.stage.StageCode; import org.codequistify.master.domain.stage.domain.Stage; import org.codequistify.master.domain.stage.dto.*; @@ -11,6 +13,9 @@ public interface StageSearchService { // 스테이지 조회 Stage getStageById(Long stageId); + // 스테이지 코드 조회 + Stage getStageByCode(StageCode stageCode); + // 스테이지 목록 조회 StagePageResponse findStagesByCriteria(SearchCriteria searchCriteria, Player player); @@ -18,12 +23,12 @@ public interface StageSearchService { QuestionResponse findQuestion(Long stageId, Integer questionIndex); // 완료한 스테이지 목록 조회 - PlayerStageProgressResponse getCompletedStagesByPlayerId(Long playerId); + PlayerStageProgressResponse getCompletedStagesByPlayerId(PlayerId playerId); // 진행중인 스테이지 목록 조회 - PlayerStageProgressResponse getInProgressStagesByPlayerId(Long playerId); + PlayerStageProgressResponse getInProgressStagesByPlayerId(PlayerId playerId); // 완료한 날짜/횟수 기록 조회 - List getHeatMapDataPointsByModifiedDate(Long playerId); + List getHeatMapDataPointsByModifiedDate(PlayerId playerId); //preview 메서드 StageResponse getStageByChoCho(String src); diff --git a/src/main/java/org/codequistify/master/domain/stage/service/impl/StageManagementServiceImpl.java b/src/main/java/org/codequistify/master/domain/stage/service/impl/StageManagementServiceImpl.java index 52a4caff..4b963137 100644 --- a/src/main/java/org/codequistify/master/domain/stage/service/impl/StageManagementServiceImpl.java +++ b/src/main/java/org/codequistify/master/domain/stage/service/impl/StageManagementServiceImpl.java @@ -1,8 +1,12 @@ package org.codequistify.master.domain.stage.service.impl; import lombok.RequiredArgsConstructor; -import org.codequistify.master.domain.lab.service.LabAssignmentService; +import org.codequistify.master.judging.application.JudgingService; +import org.codequistify.master.judging.domain.vo.JudgingAction; +import org.codequistify.master.judging.domain.vo.JudgingTarget; +import org.codequistify.master.domain.shared.stage.StageCode; import org.codequistify.master.domain.player.domain.Player; +import org.codequistify.master.domain.player.domain.PlayerId; import org.codequistify.master.domain.player.service.PlayerProfileService; import org.codequistify.master.domain.stage.convertoer.QuestionConverter; import org.codequistify.master.domain.stage.convertoer.StageConverter; @@ -21,6 +25,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Locale; + @Service @RequiredArgsConstructor public class StageManagementServiceImpl implements StageManagementService { @@ -28,7 +34,7 @@ public class StageManagementServiceImpl implements StageManagementService { private final QuestionRepository questionRepository; private final CompletedStageRepository completedStageRepository; - private final LabAssignmentService labAssignmentService; + private final JudgingService judgingService; private final PlayerProfileService playerProfileService; private final StageConverter stageConverter; @@ -48,28 +54,29 @@ public void saveStage(StageRegistryRequest request) { @Override @Transactional public GradingResponse evaluateAnswer(Player player, GradingRequest request) { - Question question = questionRepository.findByStageIdAndIndex(request.stageId(), request.questionIndex()) + Stage stage = loadStageByCode(request.stageCode()); + Long stageId = stage.getId(); + Question question = questionRepository.findByStageIdAndIndex(stageId, request.questionIndex()) .orElseThrow(() -> { - LOGGER.info("[checkAnswerCorrectness] {}, id: {}, index: {}", - ErrorCode.QUESTION_NOT_FOUND.getMessage(), request.stageId(), request.questionIndex()); + LOGGER.info("[checkAnswerCorrectness] {}, stageCode: {}, index: {}", + ErrorCode.QUESTION_NOT_FOUND.getMessage(), request.stageCode(), request.questionIndex()); return new BusinessException(ErrorCode.QUESTION_NOT_FOUND, HttpStatus.NOT_FOUND); }); boolean isCorrect; if (question.getAnswerType().equals(AnswerType.PRACTICAL)) { - Stage stage = question.getStage(); - isCorrect = this.evaluatePracticalAnswerCorrectness(player, stage, request); + isCorrect = this.evaluatePracticalAnswerCorrectness(stage, request); } else { isCorrect = this.evaluateStandardAnswerCorrectness(question, request); } boolean isLast = !questionRepository - .existsByStageIdAndIndex(request.stageId(), request.questionIndex() + 1); + .existsByStageIdAndIndex(stageId, request.questionIndex() + 1); int nextIndex = isLast ? -1 : request.questionIndex() + 1; Boolean isComposable = questionRepository - .isComposableForStageAndIndex(request.stageId(), request.questionIndex()); + .isComposableForStageAndIndex(stageId, request.questionIndex()); if (isComposable == null) { isComposable = false; } @@ -86,13 +93,13 @@ private boolean evaluateStandardAnswerCorrectness(Question question, GradingRequ return correctAnswer.equalsIgnoreCase(request.answer()); } - private boolean evaluatePracticalAnswerCorrectness(Player player, Stage stage, GradingRequest request) { - StageActionRequest stageActionRequest = new StageActionRequest( - stage.getStageImage().name(), - request.questionIndex()); + private boolean evaluatePracticalAnswerCorrectness(Stage stage, GradingRequest request) { + StageCode stageCode = StageCode.from(stage.getStageImage().name()); + JudgingTarget target = JudgingTarget.of(PlayerId.of(request.playerId()), stageCode); + JudgingAction action = JudgingAction.of(request.questionIndex()); - SuccessResponse response = labAssignmentService - .sendGradingRequest(stage.getStageImage().name(), player.getUid().toLowerCase(), stageActionRequest) + SuccessResponse response = judgingService + .requestGrading(target, action) .getBody(); return response.success(); @@ -101,21 +108,22 @@ private boolean evaluatePracticalAnswerCorrectness(Player player, Stage stage, G // compose 메서드 @Override @Transactional - public SuccessResponse composePShell(Player player, GradingRequest request) { - Question question = questionRepository.findByStageIdAndIndex(request.stageId(), request.questionIndex()) + public SuccessResponse composeVirtualWorkspace(Player player, GradingRequest request) { + Stage stage = loadStageByCode(request.stageCode()); + Long stageId = stage.getId(); + Question question = questionRepository.findByStageIdAndIndex(stageId, request.questionIndex()) .orElseThrow(() -> { - LOGGER.info("[checkAnswerCorrectness] {}, id: {}, index: {}", - ErrorCode.QUESTION_NOT_FOUND.getMessage(), request.stageId(), request.questionIndex()); + LOGGER.info("[checkAnswerCorrectness] {}, stageCode: {}, index: {}", + ErrorCode.QUESTION_NOT_FOUND.getMessage(), request.stageCode(), request.questionIndex()); return new BusinessException(ErrorCode.QUESTION_NOT_FOUND, HttpStatus.NOT_FOUND); }); - Stage stage = question.getStage(); - StageActionRequest stageActionRequest = new StageActionRequest( - stage.getStageImage().name(), - request.questionIndex()); + StageCode stageCode = StageCode.from(stage.getStageImage().name()); + JudgingTarget target = JudgingTarget.of(PlayerId.of(request.playerId()), stageCode); + JudgingAction action = JudgingAction.of(request.questionIndex()); - SuccessResponse response = labAssignmentService - .sendComposeRequest(stage.getStageImage().name(), player.getUid().toLowerCase(), stageActionRequest) + SuccessResponse response = judgingService + .requestCompose(target, action) .getBody(); return response; @@ -169,18 +177,13 @@ public StageCompletionResponse recordStageComplete(Player player, Long stageId) @Transactional public void recordInProgressStageInit(Player player, GradingRequest request) { + Stage stage = loadStageByCode(request.stageCode()); + Long stageId = stage.getId(); if (completedStageRepository - .existsByPlayerIdAndStageId(player.getId(), request.stageId())) { + .existsByPlayerIdAndStageId(player.getId(), stageId)) { return; } - Stage stage = stageRepository.findById(request.stageId()) - .orElseThrow(() -> { - LOGGER.info("[recordStageComplete] {}, stage: {}", - ErrorCode.STAGE_NOT_FOUND.getMessage(), request.stageId()); - return new BusinessException(ErrorCode.STAGE_NOT_FOUND, HttpStatus.NOT_FOUND); - }); - CompletedStage completedStage = CompletedStage.builder() .player(player) .stage(stage) @@ -188,25 +191,38 @@ public void recordInProgressStageInit(Player player, GradingRequest request) { completedStageRepository.save(completedStage); LOGGER.info("[recordInProgressStageInit] 풀이 시작 기록 stage: {}, index: {}", - request.stageId(), request.questionIndex()); + stageId, request.questionIndex()); } @Transactional public void updateInProgressStage(Player player, GradingRequest request) { + Stage stage = loadStageByCode(request.stageCode()); + Long stageId = stage.getId(); CompletedStage completedStage = completedStageRepository - .findByPlayerIdAndStageId(player.getId(), request.stageId()) + .findByPlayerIdAndStageId(player.getId(), stageId) .orElseThrow(()->{ LOGGER.info("[updateInProgressStage] {}, stage: {}", - ErrorCode.STAGE_PROGRESS_NOT_FOUND.getMessage(), request.stageId()); + ErrorCode.STAGE_PROGRESS_NOT_FOUND.getMessage(), stageId); return new BusinessException(ErrorCode.STAGE_PROGRESS_NOT_FOUND, HttpStatus.NOT_FOUND); }); completedStage.updateQuestionIndex(request.questionIndex()); completedStageRepository.save(completedStage); LOGGER.info("[updateInProgressStage] 진행정도 업데이트 stage: {}, index: {}", - request.stageId(), request.questionIndex()); + stageId, request.questionIndex()); } + private Stage loadStageByCode(String stageCode) { + StageImageType stageImageType; + try { + stageImageType = StageImageType.valueOf(stageCode.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.STAGE_NOT_FOUND, HttpStatus.NOT_FOUND, e); + } + + return stageRepository.findByStageImage(stageImageType) + .orElseThrow(() -> new BusinessException(ErrorCode.STAGE_NOT_FOUND, HttpStatus.NOT_FOUND)); + } } diff --git a/src/main/java/org/codequistify/master/domain/stage/service/impl/StageSearchServiceImpl.java b/src/main/java/org/codequistify/master/domain/stage/service/impl/StageSearchServiceImpl.java index 5422b6d4..eb71dac4 100644 --- a/src/main/java/org/codequistify/master/domain/stage/service/impl/StageSearchServiceImpl.java +++ b/src/main/java/org/codequistify/master/domain/stage/service/impl/StageSearchServiceImpl.java @@ -8,10 +8,12 @@ import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.codequistify.master.domain.player.domain.Player; +import org.codequistify.master.domain.player.domain.PlayerId; import org.codequistify.master.domain.player.dto.PlayerStageProgressResponse; import org.codequistify.master.domain.stage.convertoer.QuestionConverter; import org.codequistify.master.domain.stage.convertoer.StageConverter; import org.codequistify.master.domain.stage.domain.*; +import org.codequistify.master.domain.shared.stage.StageCode; import org.codequistify.master.domain.stage.dto.*; import org.codequistify.master.domain.stage.repository.CompletedStageRepository; import org.codequistify.master.domain.stage.repository.QuestionRepository; @@ -31,6 +33,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Locale; @RequiredArgsConstructor @Service @@ -56,6 +59,17 @@ public Stage getStageById(Long stageId) { }); } + @Override + @Transactional + public Stage getStageByCode(StageCode stageCode) { + StageImageType stageImage = parseStageImage(stageCode); + return stageRepository.findByStageImage(stageImage) + .orElseThrow(() -> { + LOGGER.info("[findStageByCode] 등록되지 않은 스테이지 코드: {}", stageCode.value()); + return new BusinessException(ErrorCode.STAGE_NOT_FOUND, HttpStatus.NOT_FOUND); + }); + } + @Override // 문항 조회 @Transactional public QuestionResponse findQuestion(Long stageId, Integer questionIndex) { @@ -116,20 +130,20 @@ public StageResponse getStageBySearchText(String query) { @Override // 완료 스테이지 조회 @Transactional - public PlayerStageProgressResponse getCompletedStagesByPlayerId(Long playerId) { - return new PlayerStageProgressResponse(completedStageRepository.findCompletedStagesByPlayerId(playerId)); + public PlayerStageProgressResponse getCompletedStagesByPlayerId(PlayerId playerId) { + return new PlayerStageProgressResponse(completedStageRepository.findCompletedStagesByPlayerId(playerId.value())); } @Override // 진행중 스테이지 조회 @Transactional - public PlayerStageProgressResponse getInProgressStagesByPlayerId(Long playerId) { - return new PlayerStageProgressResponse(completedStageRepository.findInProgressStagesByPlayerId(playerId)); + public PlayerStageProgressResponse getInProgressStagesByPlayerId(PlayerId playerId) { + return new PlayerStageProgressResponse(completedStageRepository.findInProgressStagesByPlayerId(playerId.value())); } @Override // 수정일 기준 데이터 조회 @Transactional - public List getHeatMapDataPointsByModifiedDate(Long playerId) { - return completedStageRepository.countDataByModifiedDate(playerId); + public List getHeatMapDataPointsByModifiedDate(PlayerId playerId) { + return completedStageRepository.countDataByModifiedDate(playerId.value()); } @Override // 입력 쿼리를 기반으로 일치하는 문제 조건 검색 @@ -182,6 +196,15 @@ public StagePageResponse findStagesByCriteria(SearchCriteria searchCriteria, Pla return response; } + private StageImageType parseStageImage(StageCode stageCode) { + try { + return StageImageType.valueOf(stageCode.value().trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + LOGGER.info("[parseStageImage] 등록되지 않은 스테이지 코드: {}", stageCode.value()); + throw new BusinessException(ErrorCode.STAGE_NOT_FOUND, HttpStatus.NOT_FOUND, e); + } + } + private List fetchStageResponses( BooleanBuilder whereClause, QStage qStage, diff --git a/src/main/java/org/codequistify/master/global/data/Label.java b/src/main/java/org/codequistify/master/global/data/Label.java new file mode 100644 index 00000000..95cebeca --- /dev/null +++ b/src/main/java/org/codequistify/master/global/data/Label.java @@ -0,0 +1,28 @@ +package org.codequistify.master.global.data; + +import java.util.List; +import java.util.Objects; + +public record Label(String key, List values) { + public Label { + Objects.requireNonNull(key, "key must not be null"); + Objects.requireNonNull(values, "values must not be null"); + values = List.copyOf(values); + } + + public static Label of(String key, String... values) { + return new Label(key, List.of(values)); + } + + public static Label of(String key, List values) { + return new Label(key, values); + } + + public String firstValue() { + return values.stream().findFirst().orElse(""); + } + + public Pair firstValuePair() { + return Pair.of(key, firstValue()); + } +} diff --git a/src/main/java/org/codequistify/master/global/data/Labels.java b/src/main/java/org/codequistify/master/global/data/Labels.java new file mode 100644 index 00000000..beca8c56 --- /dev/null +++ b/src/main/java/org/codequistify/master/global/data/Labels.java @@ -0,0 +1,48 @@ +package org.codequistify.master.global.data; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public record Labels(List