Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/com/saferoute/SafeRouteApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling
@SpringBootApplication
public class SafeRouteApplication {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.saferoute.domain.training.dto.CreateSessionRequest;
import com.saferoute.domain.training.dto.TrainingSessionResponse;
import com.saferoute.domain.training.service.TrainingSessionService;
import com.saferoute.global.api.response.ApiResponse;
import com.saferoute.global.api.response.TrainingSuccessCode;
import java.util.UUID;

import lombok.RequiredArgsConstructor;
Expand All @@ -26,4 +28,25 @@ public ResponseEntity<TrainingSessionResponse> createTrainingSession(
@PathVariable("scenarioId") UUID scenarioId) {
return ResponseEntity.ok(trainingSessionService.create(request, scenarioId));
}

@PostMapping("/{sessionId}/start")
public ResponseEntity<ApiResponse<TrainingSessionResponse>> startTrainingSession(
@PathVariable("sessionId") UUID sessionId) {
TrainingSessionResponse response = trainingSessionService.start(sessionId);
return ResponseEntity.ok(ApiResponse.success(TrainingSuccessCode.TRAINING_STARTED, response));
}

@PostMapping("/{sessionId}/end")
public ResponseEntity<ApiResponse<TrainingSessionResponse>> endTrainingSession(
@PathVariable("sessionId") UUID sessionId) {
TrainingSessionResponse response = trainingSessionService.end(sessionId);
return ResponseEntity.ok(ApiResponse.success(TrainingSuccessCode.TRAINING_ENDED, response));
}

@PostMapping("/{sessionId}/force-end")
public ResponseEntity<ApiResponse<TrainingSessionResponse>> forceEndTrainingSession(
@PathVariable("sessionId") UUID sessionId) {
TrainingSessionResponse response = trainingSessionService.forceEnd(sessionId);
return ResponseEntity.ok(ApiResponse.success(TrainingSuccessCode.TRAINING_FORCE_ENDED, response));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@
import com.saferoute.domain.training.entity.TrainingStatus;
import com.saferoute.domain.training.entity.TrainingSession;
import java.time.Instant;
import java.util.UUID;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class TrainingSessionResponse {
private UUID id;
private TrainingStatus status;
private Instant startedAt;
private String adminName;
private String scenarioName;

public static TrainingSessionResponse from(TrainingSession session) {
return TrainingSessionResponse.builder()
.id(session.getId())
.status(session.getStatus())
.startedAt(session.getStartedAt())
.adminName(session.getAdmin().getUsername())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public class TrainingSession {
@GeneratedValue
private UUID id;

@Version
private Long version;

//훈련 상태 (RUNNING, STOPPED 등)
@NotNull
@Enumerated(EnumType.STRING)
Expand Down Expand Up @@ -78,6 +81,12 @@ public static TrainingSession create(TrainingStatus status, Instant startedAt, U
return new TrainingSession(status, startedAt, admin, scenario);
}

// 관리자가 훈련 시작 버튼을 누른 시각으로 실제 시작 시각을 갱신하며 RUNNING으로 전이한다.
public void start(Instant startedAt) {
this.status = TrainingStatus.RUNNING;
this.startedAt = startedAt;
}

// 훈련 정상 종료
public void complete(Instant endedAt) {
this.status = TrainingStatus.COMPLETED;
Expand Down
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
Expand Up @@ -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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (user.getRole() != UserRole.MANAGER) {
throw new ApiException(ErrorCode.FORBIDDEN);
}
TrainingScenario scenario = trainingScenarioRepository.findById(scenarioId)
.orElseThrow(() -> new ApiException(TrainingErrorCode.TRAINING_SCENARIO_NOT_FOUND));
Comment thread
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(),
Expand All @@ -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();
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

상태 확인과 상태 갱신을 원자적으로 처리하십시오.

start, end, forceEnd, 타임아웃 처리는 상태를 읽고 검증한 뒤 갱신합니다. 행 잠금과 @Version이 없으므로 두 트랜잭션이 같은 RUNNING 상태를 동시에 읽을 수 있습니다. 예를 들어 정상 종료와 강제 종료가 모두 성공한 뒤 마지막 커밋이 최종 상태를 덮어쓸 수 있습니다. 모든 상태 전이와 타임아웃 조회에 낙관적 잠금과 충돌 처리 또는 비관적 행 잠금을 적용하십시오.

Also applies to: 127-138

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java`
around lines 85 - 125, Make the status validation and update atomic across
start, end, forceEnd, and timeout handling by applying locking to the
TrainingSession entity or its lookup method, using either `@Version` with conflict
handling or a pessimistic row lock. Ensure concurrent transitions cannot both
succeed or overwrite the final state, and apply the same protection to
timeout-related session reads.


// 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
Expand Up @@ -8,6 +8,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
Expand All @@ -28,6 +29,11 @@ public ResponseEntity<ApiResponse<Void>> handleApiException(ApiException excepti
return response(exception.getErrorCode(), null);
}

@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ApiResponse<Void>> handleOptimisticLockingFailure() {
return response(ErrorCode.CONFLICT, null);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(
MethodArgumentNotValidException exception
Expand Down
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;
}
Loading
Loading