-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 훈련 시작/종료 API 구현 - #40 #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,13 @@ | ||
| package com.saferoute.domain.training.repository; | ||
|
|
||
| import com.saferoute.domain.training.entity.TrainingSession; | ||
| import com.saferoute.domain.training.entity.TrainingStatus; | ||
| import java.time.Instant; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface TrainingSessionRepository extends JpaRepository<TrainingSession, UUID> { | ||
|
|
||
| List<TrainingSession> findByStatusAndStartedAtBefore(TrainingStatus status, Instant threshold); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.saferoute.domain.training.scheduler; | ||
|
|
||
| import com.saferoute.domain.training.service.TrainingSessionService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| // RUNNING 세션 중 10분 하드 타임아웃(TrainingSessionService.TRAINING_TIMEOUT)을 넘긴 세션을 | ||
| // 주기적으로 스캔해 FAILED 처리한다. 텔레메트리 기반 3분 공백 정밀 판정은 카메라 연동 후 별도 구현. | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class TrainingTimeoutScheduler { | ||
|
|
||
| private static final long SCAN_INTERVAL_MS = 30_000; | ||
|
|
||
| private final TrainingSessionService trainingSessionService; | ||
|
|
||
| @Scheduled(fixedDelay = SCAN_INTERVAL_MS) | ||
| public void scanTimedOutSessions() { | ||
| trainingSessionService.failTimedOutSessions(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,28 +12,48 @@ | |
| import com.saferoute.domain.training.repository.TrainingScenarioRepository; | ||
| import com.saferoute.domain.training.repository.TrainingSessionRepository; | ||
| import com.saferoute.domain.user.entity.User; | ||
| import com.saferoute.domain.user.entity.UserRole; | ||
| import com.saferoute.domain.user.repository.UserRepository; | ||
| import com.saferoute.global.api.code.ErrorCode; | ||
| import com.saferoute.global.api.error.TrainingErrorCode; | ||
| import com.saferoute.global.api.exception.ApiException; | ||
| import com.saferoute.infrastructure.websocket.service.TrainingEventPublisher; | ||
| import java.math.BigDecimal; | ||
| import java.time.Duration; | ||
| import java.time.Instant; | ||
| import java.util.NoSuchElementException; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class TrainingSessionService { | ||
|
|
||
| // 텔레메트리(personCount) 수신 파이프라인이 아직 없어, 3분 공백 기반 정밀 타임아웃 판정 대신 | ||
| // 1단계로 RUNNING 세션에 대한 단순 하드 타임아웃만 적용한다. 카메라 연동 완료 후 별도 이슈에서 대체 예정. | ||
| public static final Duration TRAINING_TIMEOUT = Duration.ofMinutes(10); | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final TrainingSessionRepository trainingSessionRepository; | ||
| private final TrainingScenarioRepository trainingScenarioRepository; | ||
| private final TrainingEventPublisher trainingEventPublisher; | ||
|
|
||
| public TrainingSessionResponse create(CreateSessionRequest request, UUID scenarioId) { | ||
| User user = userRepository.findById(request.getAdminId()) | ||
| .orElseThrow(NoSuchElementException::new); | ||
| TrainingScenario scenario = trainingScenarioRepository.findById(scenarioId).orElseThrow( | ||
| NoSuchElementException::new); | ||
| .orElseThrow(() -> new ApiException(TrainingErrorCode.ADMIN_NOT_FOUND)); | ||
| if (user.getRole() != UserRole.MANAGER) { | ||
| throw new ApiException(ErrorCode.FORBIDDEN); | ||
| } | ||
| TrainingScenario scenario = trainingScenarioRepository.findById(scenarioId) | ||
| .orElseThrow(() -> new ApiException(TrainingErrorCode.TRAINING_SCENARIO_NOT_FOUND)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (request.getStatus() == TrainingStatus.RUNNING && request.getStartedAt() == null) { | ||
| throw new ApiException(ErrorCode.INVALID_INPUT); | ||
| } | ||
|
|
||
| TrainingSession trainingSession = TrainingSession.create( | ||
| request.getStatus(), | ||
|
|
@@ -47,8 +67,7 @@ public TrainingSessionResponse create(CreateSessionRequest request, UUID scenari | |
|
|
||
| @Transactional(readOnly = true) | ||
| public TrainingStatusResponse getTrainingStatus(UUID sessionId) { | ||
| TrainingSession session = trainingSessionRepository.findById(sessionId).orElseThrow( | ||
| NoSuchElementException::new); | ||
| TrainingSession session = findSession(sessionId); | ||
|
|
||
| TrainingScenario scenario = session.getScenario(); | ||
| Building building = scenario.getBuilding(); | ||
|
|
@@ -69,6 +88,67 @@ public TrainingStatusResponse getTrainingStatus(UUID sessionId) { | |
| session.getCurrentSurvivalRate() != null ? session.getCurrentSurvivalRate() : BigDecimal.ZERO | ||
| ); | ||
| } | ||
| throw new IllegalStateException("지원하지 않는 상태입니다: " + session.getStatus()); | ||
| throw new ApiException(TrainingErrorCode.UNSUPPORTED_STATUS); | ||
| } | ||
|
|
||
| @Transactional | ||
| public TrainingSessionResponse start(UUID sessionId) { | ||
| TrainingSession session = findSession(sessionId); | ||
|
|
||
| if (session.getStatus() != TrainingStatus.SCHEDULED) { | ||
| throw new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION); | ||
| } | ||
|
|
||
| session.start(Instant.now()); | ||
| trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); | ||
|
|
||
| return TrainingSessionResponse.from(session); | ||
| } | ||
|
|
||
| @Transactional | ||
| public TrainingSessionResponse end(UUID sessionId) { | ||
| TrainingSession session = findSession(sessionId); | ||
|
|
||
| if (session.getStatus() != TrainingStatus.RUNNING) { | ||
| throw new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION); | ||
| } | ||
|
|
||
| session.complete(Instant.now()); | ||
| trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); | ||
|
|
||
| return TrainingSessionResponse.from(session); | ||
| } | ||
|
|
||
| @Transactional | ||
| public TrainingSessionResponse forceEnd(UUID sessionId) { | ||
| TrainingSession session = findSession(sessionId); | ||
|
|
||
| if (session.getStatus() != TrainingStatus.RUNNING) { | ||
| throw new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION); | ||
| } | ||
|
|
||
| session.stop(Instant.now()); | ||
| trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); | ||
|
|
||
| return TrainingSessionResponse.from(session); | ||
| } | ||
|
Comment on lines
+94
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 상태 확인과 상태 갱신을 원자적으로 처리하십시오.
Also applies to: 127-138 🤖 Prompt for AI Agents |
||
|
|
||
| // 10분 하드 타임아웃을 넘긴 RUNNING 세션을 스케줄러가 주기적으로 호출해 FAILED 처리한다. | ||
| @Transactional | ||
| public void failTimedOutSessions() { | ||
| Instant threshold = Instant.now().minus(TRAINING_TIMEOUT); | ||
| List<TrainingSession> timedOutSessions = | ||
| trainingSessionRepository.findByStatusAndStartedAtBefore(TrainingStatus.RUNNING, threshold); | ||
|
|
||
| for (TrainingSession session : timedOutSessions) { | ||
| session.fail(Instant.now()); | ||
| trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); | ||
| log.info("훈련 세션 타임아웃 처리: sessionId={}", session.getId()); | ||
| } | ||
| } | ||
|
|
||
| private TrainingSession findSession(UUID sessionId) { | ||
| return trainingSessionRepository.findById(sessionId) | ||
| .orElseThrow(() -> new ApiException(TrainingErrorCode.TRAINING_SESSION_NOT_FOUND)); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.saferoute.global.api.error; | ||
|
|
||
| import com.saferoute.global.api.code.BaseErrorCode; | ||
| import lombok.Getter; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| @Getter | ||
| @RequiredArgsConstructor | ||
| public enum TrainingErrorCode implements BaseErrorCode { | ||
|
|
||
| TRAINING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "TRAINING001", "훈련 세션을 찾을 수 없습니다."), | ||
| ADMIN_NOT_FOUND(HttpStatus.NOT_FOUND, "TRAINING002", "관리자를 찾을 수 없습니다."), | ||
| TRAINING_SCENARIO_NOT_FOUND(HttpStatus.NOT_FOUND, "TRAINING003", "훈련 시나리오를 찾을 수 없습니다."), | ||
| INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TRAINING004", "현재 상태에서는 요청한 전이를 수행할 수 없습니다."), | ||
| UNSUPPORTED_STATUS(HttpStatus.CONFLICT, "TRAINING005", "지원하지 않는 훈련 상태입니다."); | ||
|
|
||
| private final HttpStatus httpStatus; | ||
| private final String code; | ||
| private final String message; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.saferoute.global.api.response; | ||
|
|
||
| import com.saferoute.global.api.code.BaseCode; | ||
| import lombok.Getter; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| @Getter | ||
| @RequiredArgsConstructor | ||
| public enum TrainingSuccessCode implements BaseCode { | ||
|
|
||
| TRAINING_SESSION_CREATED(HttpStatus.CREATED, "TRAINING_SUCCESS_001", "훈련 세션이 생성되었습니다."), | ||
| TRAINING_STATUS_FOUND(HttpStatus.OK, "TRAINING_SUCCESS_002", "훈련 상태 조회에 성공했습니다."), | ||
| TRAINING_STARTED(HttpStatus.OK, "TRAINING_SUCCESS_003", "훈련이 시작되었습니다."), | ||
| TRAINING_ENDED(HttpStatus.OK, "TRAINING_SUCCESS_004", "훈련이 정상 종료되었습니다."), | ||
| TRAINING_FORCE_ENDED(HttpStatus.OK, "TRAINING_SUCCESS_005", "훈련이 강제 종료되었습니다."); | ||
|
|
||
| private final HttpStatus httpStatus; | ||
| private final String code; | ||
| private final String message; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.