diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 77c31474..b0c3b697 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -14,4 +14,4 @@ resources: - minio/service.yaml images: - name: ghcr.io/semosan/semosan_be - newTag: 14e02fc87a6a73e49418a399891e97c2b839b3df + newTag: fb0eb2823ad7e4ee955bd3941443be46e88a22cf diff --git a/src/main/java/com/semosan/api/ApiApplication.java b/src/main/java/com/semosan/api/ApiApplication.java index 4d2853ee..fcc7e904 100644 --- a/src/main/java/com/semosan/api/ApiApplication.java +++ b/src/main/java/com/semosan/api/ApiApplication.java @@ -5,8 +5,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing +@EnableScheduling @SpringBootApplication @EnableConfigurationProperties(KakaoProperties.class) public class ApiApplication { diff --git a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java index 8e87351b..fba72956 100644 --- a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java +++ b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java @@ -3,6 +3,7 @@ import com.semosan.api.common.base.BaseStatus; import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.common.response.ApiResponse; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @Slf4j @@ -39,6 +41,32 @@ public ResponseEntity> handleIllegalArgumentException( return ApiResponse.error(ErrorStatus.BAD_REQUEST); } + // @Validated 컨트롤러의 @RequestParam/@PathVariable 단순 타입 검증 실패 (Spring 6 MethodValidationInterceptor 경로) + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation( + ConstraintViolationException e + ) { + String message = e.getConstraintViolations().stream() + .findFirst() + .map(v -> v.getPropertyPath().toString() + ": " + v.getMessage()) + .orElse(ErrorStatus.BAD_REQUEST.getMessage()); + log.warn("[*] ConstraintViolationException : {}", message); + return ApiResponse.error(ErrorStatus.BAD_REQUEST, message); + } + + // Spring 6.1+ HandlerMethodValidationException (컨트롤러 메서드 파라미터 검증 실패 표준 경로) + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleHandlerMethodValidation( + HandlerMethodValidationException e + ) { + String message = e.getAllErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : ErrorStatus.BAD_REQUEST.getMessage()) + .orElse(ErrorStatus.BAD_REQUEST.getMessage()); + log.warn("[*] HandlerMethodValidationException : {}", message); + return ApiResponse.error(ErrorStatus.BAD_REQUEST, message); + } + // null 참조로 발생한 서버 오류를 500 에러로 응답 @ExceptionHandler(NullPointerException.class) public ResponseEntity> handleNullPointerException( diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index d92f7221..d4e6bbc0 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -105,6 +105,17 @@ public enum ErrorStatus implements BaseStatus { HIKING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "HIKE_404_1", "등산 기록을 찾을 수 없습니다."), HIKING_RECORD_FORBIDDEN(HttpStatus.FORBIDDEN, "HIKE_403_1", "본인이 참여한 등산 기록만 공유할 수 있습니다."), + /** + * Tracking (트래킹 세션) + */ + TRACKING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_1", "트래킹 세션을 찾을 수 없습니다."), + TRACKING_SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "TRK_403_1", "본인의 트래킹 세션만 조작할 수 있습니다."), + TRACKING_SESSION_ALREADY_IN_PROGRESS(HttpStatus.CONFLICT, "TRK_409_1", "이미 진행 중인 트래킹 세션이 있습니다."), + TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."), + TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."), + TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."), + COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."), + /** * Post (게시글 공통) */ diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index a1566375..6034906c 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -52,9 +52,16 @@ public enum SuccessStatus implements BaseStatus { GET_HIKING_RECORD_LIST_BY_MOUNTAIN_SUCCESS(HttpStatus.OK, "HIKING_200_4", "특정 산의 나의 등산 기록 목록 조회에 성공했습니다."), /** - * Tracking + * Tracking — 진입 화면 (#45) & 세션 (#18) */ TRACKING_NEAREST_MOUNTAIN_SUCCESS(HttpStatus.OK, "TRK_200_1", "현재 위치 기준 가까운 산과 코스 조회에 성공했습니다."), + TRACKING_SESSION_CREATE_SUCCESS(HttpStatus.CREATED, "TRK_201_1", "트래킹 세션이 시작되었습니다."), + TRACKING_SESSION_GET_ACTIVE_SUCCESS(HttpStatus.OK, "TRK_200_2", "현재 진행 중인 트래킹 세션 조회에 성공했습니다."), + TRACKING_SESSION_GET_SUCCESS(HttpStatus.OK, "TRK_200_3", "트래킹 세션 상세 조회에 성공했습니다."), + TRACKING_SESSION_PAUSE_SUCCESS(HttpStatus.OK, "TRK_200_4", "트래킹 세션을 일시정지했습니다."), + TRACKING_SESSION_RESUME_SUCCESS(HttpStatus.OK, "TRK_200_5", "트래킹 세션을 재개했습니다."), + TRACKING_SESSION_COMPLETE_SUCCESS(HttpStatus.OK, "TRK_200_6", "트래킹 세션을 종료했습니다."), + TRACKING_SESSION_ABANDON_SUCCESS(HttpStatus.OK, "TRK_200_7", "트래킹 세션을 포기 처리했습니다."), /** * Image diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java index c3033ea0..223de48b 100644 --- a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java +++ b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java @@ -5,9 +5,12 @@ import com.semosan.api.domain.tracking.controller.docs.TrackingControllerDocs; import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse; import com.semosan.api.domain.tracking.service.TrackingService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -16,6 +19,7 @@ @RestController @RequestMapping("/api/tracking") @RequiredArgsConstructor +@Validated public class TrackingController implements TrackingControllerDocs { private final TrackingService trackingService; @@ -23,14 +27,15 @@ public class TrackingController implements TrackingControllerDocs { /** * 트래킹 진입 화면에서 호출. * 프론트가 디바이스 GPS 좌표(lat, lng)를 보내면, 가장 가까운 산과 그 산의 코스 목록을 반환한다. - * 인증 필수 — 트래킹은 로그인 사용자만 시작 가능. + * 인증 필수 — 트래킹은 로그인 사용자만 시작 가능 (userId 는 Spring Security 인증 통과 강제용). + * 사용자별 개인화는 추후 도입 시 service 로 userId 를 넘겨 활용. */ @GetMapping("/nearby-mountain") @Override public ResponseEntity> getNearbyMountain( @AuthenticationPrincipal Long userId, - @RequestParam Double lat, - @RequestParam Double lng + @RequestParam @Min(-90) @Max(90) Double lat, + @RequestParam @Min(-180) @Max(180) Double lng ) { NearbyMountainResponse response = trackingService.getNearbyMountain(userId, lat, lng); return ApiResponse.success(SuccessStatus.TRACKING_NEAREST_MOUNTAIN_SUCCESS, response); diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingSessionController.java b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingSessionController.java new file mode 100644 index 00000000..b5aaa32f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingSessionController.java @@ -0,0 +1,93 @@ +package com.semosan.api.domain.tracking.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.tracking.controller.docs.TrackingSessionControllerDocs; +import com.semosan.api.domain.tracking.dto.request.CreateTrackingSessionRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingSessionResponse; +import com.semosan.api.domain.tracking.service.TrackingSessionService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/tracking/sessions") +@RequiredArgsConstructor +public class TrackingSessionController implements TrackingSessionControllerDocs { + + private final TrackingSessionService trackingSessionService; + + @PostMapping + @Override + public ResponseEntity> createSession( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody CreateTrackingSessionRequest request + ) { + TrackingSessionResponse response = trackingSessionService.create(userId, request); + return ApiResponse.success(SuccessStatus.TRACKING_SESSION_CREATE_SUCCESS, response); + } + + /** + * 앱 재진입 시 호출. 진행 중 세션이 없으면 data 가 비어 응답된다 (NON_NULL 직렬화). + */ + @GetMapping("/me/active") + @Override + public ResponseEntity> getActiveSession( + @AuthenticationPrincipal Long userId + ) { + TrackingSessionResponse response = trackingSessionService.getActive(userId).orElse(null); + return ApiResponse.success(SuccessStatus.TRACKING_SESSION_GET_ACTIVE_SUCCESS, response); + } + + @GetMapping("/{sessionId}") + @Override + public ResponseEntity> getSession( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ) { + TrackingSessionResponse response = trackingSessionService.get(userId, sessionId); + return ApiResponse.success(SuccessStatus.TRACKING_SESSION_GET_SUCCESS, response); + } + + @PostMapping("/{sessionId}/pause") + @Override + public ResponseEntity> pauseSession( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ) { + TrackingSessionResponse response = trackingSessionService.pause(userId, sessionId); + return ApiResponse.success(SuccessStatus.TRACKING_SESSION_PAUSE_SUCCESS, response); + } + + @PostMapping("/{sessionId}/resume") + @Override + public ResponseEntity> resumeSession( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ) { + TrackingSessionResponse response = trackingSessionService.resume(userId, sessionId); + return ApiResponse.success(SuccessStatus.TRACKING_SESSION_RESUME_SUCCESS, response); + } + + @PostMapping("/{sessionId}/complete") + @Override + public ResponseEntity> completeSession( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ) { + TrackingSessionResponse response = trackingSessionService.complete(userId, sessionId); + return ApiResponse.success(SuccessStatus.TRACKING_SESSION_COMPLETE_SUCCESS, response); + } + + @PostMapping("/{sessionId}/abandon") + @Override + public ResponseEntity> abandonSession( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ) { + TrackingSessionResponse response = trackingSessionService.abandon(userId, sessionId); + return ApiResponse.success(SuccessStatus.TRACKING_SESSION_ABANDON_SUCCESS, response); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingSessionControllerDocs.java b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingSessionControllerDocs.java new file mode 100644 index 00000000..b0687493 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingSessionControllerDocs.java @@ -0,0 +1,87 @@ +package com.semosan.api.domain.tracking.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.tracking.dto.request.CreateTrackingSessionRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingSessionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Tracking Session", description = "트래킹 세션 관리 API") +public interface TrackingSessionControllerDocs { + + @Operation( + summary = "트래킹 세션 시작", + description = "산/코스 정보로 새 트래킹 세션을 생성합니다. 자유 기록(isFreeRecording=true)이면 courseId 는 무시됩니다. " + + "유저당 진행 중 세션이 이미 있으면 409 응답." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "세션 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "산 또는 코스 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 진행 중인 세션 존재", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ResponseEntity> createSession( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @Valid @RequestBody CreateTrackingSessionRequest request + ); + + @Operation(summary = "현재 진행 중 트래킹 세션 조회", + description = "앱 재진입 시 호출. 진행 중(IN_PROGRESS/PAUSED) 세션이 없으면 data 가 비어 응답됩니다.") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공")}) + ResponseEntity> getActiveSession( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId + ); + + @Operation(summary = "트래킹 세션 상세 조회", description = "본인 소유 세션만 조회 가능합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인 세션 아님", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "세션 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ResponseEntity> getSession( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @Parameter(description = "세션 ID", required = true) @PathVariable Long sessionId + ); + + @Operation(summary = "트래킹 세션 일시정지", description = "IN_PROGRESS 상태에서만 가능. 점 수집은 멈추고 duration 에서 제외됩니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "일시정지 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "현재 상태에서 일시정지 불가", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ResponseEntity> pauseSession( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ); + + @Operation(summary = "트래킹 세션 재개", description = "PAUSED 상태에서만 가능. 일시정지 누적 시간이 합산됩니다.") + ResponseEntity> resumeSession( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ); + + @Operation(summary = "트래킹 세션 정상 종료", + description = "세션 상태/시각만 마감합니다. 실제 HikingRecord 변환은 #20 에서 구현됩니다.") + ResponseEntity> completeSession( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ); + + @Operation(summary = "트래킹 세션 포기", description = "기록 없이 세션을 ABANDONED 처리합니다.") + ResponseEntity> abandonSession( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ); +} diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/request/CreateTrackingSessionRequest.java b/src/main/java/com/semosan/api/domain/tracking/dto/request/CreateTrackingSessionRequest.java new file mode 100644 index 00000000..a2859997 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/request/CreateTrackingSessionRequest.java @@ -0,0 +1,15 @@ +package com.semosan.api.domain.tracking.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record CreateTrackingSessionRequest( + @NotNull(message = "산 ID는 필수입니다.") + Long mountainId, + + /** 자유 기록(코스 미선택) 인 경우 null */ + Long courseId, + + @NotNull(message = "자유 기록 여부는 필수입니다.") + Boolean isFreeRecording +) { +} diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java new file mode 100644 index 00000000..f05596aa --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java @@ -0,0 +1,39 @@ +package com.semosan.api.domain.tracking.dto.response; + +import com.semosan.api.domain.tracking.entity.TrackingSession; +import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; + +import java.time.LocalDateTime; + +public record TrackingSessionResponse( + Long sessionId, + Long userId, + Long mountainId, + String mountainName, + Long courseId, + String courseName, + Boolean isFreeRecording, + TrackingSessionStatus status, + LocalDateTime startedAt, + LocalDateTime endedAt, + LocalDateTime pausedAt, + Integer pausedSecondsTotal +) { + + public static TrackingSessionResponse from(TrackingSession session) { + return new TrackingSessionResponse( + session.getId(), + session.getUser().getId(), + session.getMountain().getId(), + session.getMountain().getName(), + session.getCourse() != null ? session.getCourse().getId() : null, + session.getCourse() != null ? session.getCourse().getName() : null, + session.getIsFreeRecording(), + session.getStatus(), + session.getStartedAt(), + session.getEndedAt(), + session.getPausedAt(), + session.getPausedSecondsTotal() + ); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/entity/TrackingSession.java b/src/main/java/com/semosan/api/domain/tracking/entity/TrackingSession.java new file mode 100644 index 00000000..69bf9fdf --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/entity/TrackingSession.java @@ -0,0 +1,135 @@ +package com.semosan.api.domain.tracking.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; +import com.semosan.api.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.Set; + +@Table(name = "tracking_sessions") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackingSession extends BaseEntity { + + private static final Set TERMINAL_STATES = + EnumSet.of(TrackingSessionStatus.COMPLETED, TrackingSessionStatus.ABANDONED); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + /** 자유 기록 시 null */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id") + private Course course; + + @Column(name = "is_free_recording", nullable = false) + private Boolean isFreeRecording; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private TrackingSessionStatus status; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "ended_at") + private LocalDateTime endedAt; + + /** 현재 일시정지 중인 경우의 시작 시각. resume 시 누적 후 null 로 리셋. */ + @Column(name = "paused_at") + private LocalDateTime pausedAt; + + /** 누적 일시정지 시간 (초). duration 계산 시 제외. */ + @Column(name = "paused_seconds_total", nullable = false) + private Integer pausedSecondsTotal; + + public static TrackingSession create(User user, Mountain mountain, Course course, boolean isFreeRecording) { + return TrackingSession.builder() + .user(user) + .mountain(mountain) + .course(course) + .isFreeRecording(isFreeRecording) + .status(TrackingSessionStatus.IN_PROGRESS) + .startedAt(LocalDateTime.now()) + .pausedSecondsTotal(0) + .build(); + } + + public void pause() { + if (status != TrackingSessionStatus.IN_PROGRESS) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_INVALID_STATE); + } + this.status = TrackingSessionStatus.PAUSED; + this.pausedAt = LocalDateTime.now(); + } + + public void resume() { + if (status != TrackingSessionStatus.PAUSED) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_INVALID_STATE); + } + if (pausedAt != null) { + int elapsed = (int) Duration.between(pausedAt, LocalDateTime.now()).toSeconds(); + this.pausedSecondsTotal = pausedSecondsTotal + Math.max(elapsed, 0); + this.pausedAt = null; + } + this.status = TrackingSessionStatus.IN_PROGRESS; + } + + public void complete() { + if (TERMINAL_STATES.contains(status)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_INVALID_STATE); + } + finalizePausedSeconds(); + this.status = TrackingSessionStatus.COMPLETED; + this.endedAt = LocalDateTime.now(); + } + + public void abandon() { + if (TERMINAL_STATES.contains(status)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_INVALID_STATE); + } + finalizePausedSeconds(); + this.status = TrackingSessionStatus.ABANDONED; + this.endedAt = LocalDateTime.now(); + } + + /** + * 종료 시점에 만약 일시정지 중이었다면 그 구간을 누적에 반영한 뒤 pausedAt 리셋. + */ + private void finalizePausedSeconds() { + if (status == TrackingSessionStatus.PAUSED && pausedAt != null) { + int elapsed = (int) Duration.between(pausedAt, LocalDateTime.now()).toSeconds(); + this.pausedSecondsTotal = pausedSecondsTotal + Math.max(elapsed, 0); + this.pausedAt = null; + } + } + + public boolean isOwnedBy(Long userId) { + return user != null && userId != null && userId.equals(user.getId()); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/enums/TrackingSessionStatus.java b/src/main/java/com/semosan/api/domain/tracking/enums/TrackingSessionStatus.java new file mode 100644 index 00000000..d4d84b5f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/enums/TrackingSessionStatus.java @@ -0,0 +1,18 @@ +package com.semosan.api.domain.tracking.enums; + +import java.util.EnumSet; +import java.util.Set; + +public enum TrackingSessionStatus { + /** 트래킹 진행 중 */ + IN_PROGRESS, + /** 사용자 일시정지 — 점 수집 멈춤, duration 에서 제외 */ + PAUSED, + /** 정상 종료 — HikingRecord 변환은 #20 에서 수행 */ + COMPLETED, + /** 사용자 포기 또는 24h 무업데이트로 자동 만료 */ + ABANDONED; + + /** 활성(트래킹 가능) 상태 집합. 진행/일시정지 모두 포함. */ + public static final Set ACTIVE_STATES = EnumSet.of(IN_PROGRESS, PAUSED); +} diff --git a/src/main/java/com/semosan/api/domain/tracking/repository/TrackingSessionRepository.java b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingSessionRepository.java new file mode 100644 index 00000000..79621b4d --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingSessionRepository.java @@ -0,0 +1,38 @@ +package com.semosan.api.domain.tracking.repository; + +import com.semosan.api.domain.tracking.entity.TrackingSession; +import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface TrackingSessionRepository extends JpaRepository { + + /** 유저당 진행 중 세션이 이미 있는지 — 409 분기에 사용. */ + boolean existsByUser_IdAndStatusIn(Long userId, Collection statuses); + + /** 유저의 활성(IN_PROGRESS/PAUSED) 세션 1건 조회 — 앱 재진입 시. */ + Optional findFirstByUser_IdAndStatusInOrderByStartedAtDesc( + Long userId, + Collection statuses + ); + + /** + * 24h 자동 만료 스케줄러용. + * 활성 상태(IN_PROGRESS/PAUSED) 이면서 마지막 업데이트가 cutoff 이전인 세션을 찾는다. + */ + @Query(""" + SELECT ts FROM TrackingSession ts + WHERE ts.status IN :statuses + AND ts.updatedAt < :cutoff + """) + List findStaleActiveSessions( + @Param("statuses") Collection statuses, + @Param("cutoff") LocalDateTime cutoff + ); +} diff --git a/src/main/java/com/semosan/api/domain/tracking/scheduler/TrackingSessionExpiryScheduler.java b/src/main/java/com/semosan/api/domain/tracking/scheduler/TrackingSessionExpiryScheduler.java new file mode 100644 index 00000000..191d2837 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/scheduler/TrackingSessionExpiryScheduler.java @@ -0,0 +1,46 @@ +package com.semosan.api.domain.tracking.scheduler; + +import com.semosan.api.domain.tracking.entity.TrackingSession; +import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; +import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 24h 동안 어떤 업데이트도 없는 활성(IN_PROGRESS/PAUSED) 세션을 자동으로 ABANDONED 처리한다. + * - 사용자가 앱을 닫고 잊거나, 네트워크 단절로 종료 신호가 안 들어온 케이스 정리. + * - 매 1시간마다 실행. + * + * TODO: 만료 정책(24h) 변경 시 environment property 화. 기록 정리 시 알림 발송 여부 검토. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TrackingSessionExpiryScheduler { + + private static final Duration EXPIRY_THRESHOLD = Duration.ofHours(24); + + private final TrackingSessionRepository trackingSessionRepository; + + @Scheduled(cron = "0 0 * * * *") // 매 정시 0분 (1시간 간격) + @Transactional + public void expireStaleSessions() { + LocalDateTime cutoff = LocalDateTime.now().minus(EXPIRY_THRESHOLD); + List stale = trackingSessionRepository.findStaleActiveSessions( + TrackingSessionStatus.ACTIVE_STATES, cutoff); + if (stale.isEmpty()) { + return; + } + for (TrackingSession session : stale) { + session.abandon(); + } + log.info("Expired {} stale tracking sessions (cutoff={})", stale.size(), cutoff); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java new file mode 100644 index 00000000..7dde25fd --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -0,0 +1,114 @@ +package com.semosan.api.domain.tracking.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.repository.CourseRepository; +import com.semosan.api.domain.mountain.repository.MountainRepository; +import com.semosan.api.domain.tracking.dto.request.CreateTrackingSessionRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingSessionResponse; +import com.semosan.api.domain.tracking.entity.TrackingSession; +import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; +import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.service.UserReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrackingSessionService { + + private final TrackingSessionRepository trackingSessionRepository; + private final MountainRepository mountainRepository; + private final CourseRepository courseRepository; + private final UserReader userReader; + + @Transactional + public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest request) { + if (trackingSessionRepository.existsByUser_IdAndStatusIn(userId, TrackingSessionStatus.ACTIVE_STATES)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS); + } + User user = userReader.findActiveUserById(userId); + Mountain mountain = mountainRepository.findById(request.mountainId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.MOUNTAIN_NOT_FOUND)); + + Course course = resolveCourse(request, mountain); + + TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording()); + TrackingSession saved = trackingSessionRepository.save(session); + return TrackingSessionResponse.from(saved); + } + + public Optional getActive(Long userId) { + return trackingSessionRepository + .findFirstByUser_IdAndStatusInOrderByStartedAtDesc(userId, TrackingSessionStatus.ACTIVE_STATES) + .map(TrackingSessionResponse::from); + } + + public TrackingSessionResponse get(Long userId, Long sessionId) { + return TrackingSessionResponse.from(findOwnedSession(userId, sessionId)); + } + + @Transactional + public TrackingSessionResponse pause(Long userId, Long sessionId) { + TrackingSession session = findOwnedSession(userId, sessionId); + session.pause(); + return TrackingSessionResponse.from(session); + } + + @Transactional + public TrackingSessionResponse resume(Long userId, Long sessionId) { + TrackingSession session = findOwnedSession(userId, sessionId); + session.resume(); + return TrackingSessionResponse.from(session); + } + + /** + * 정상 종료. 본 메서드는 상태/시각만 마감하고, 실제 HikingRecord 변환은 #20 에서 추가될 예정. + * TODO(#20): 종료 시 Redis Stream 의 GPS 점들을 모아 통계 계산 + HikingRecord/HikingMember 생성. + */ + @Transactional + public TrackingSessionResponse complete(Long userId, Long sessionId) { + TrackingSession session = findOwnedSession(userId, sessionId); + session.complete(); + return TrackingSessionResponse.from(session); + } + + @Transactional + public TrackingSessionResponse abandon(Long userId, Long sessionId) { + TrackingSession session = findOwnedSession(userId, sessionId); + session.abandon(); + return TrackingSessionResponse.from(session); + } + + private TrackingSession findOwnedSession(Long userId, Long sessionId) { + TrackingSession session = trackingSessionRepository.findById(sessionId) + .orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND)); + if (!session.isOwnedBy(userId)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN); + } + return session; + } + + private Course resolveCourse(CreateTrackingSessionRequest request, Mountain mountain) { + if (Boolean.TRUE.equals(request.isFreeRecording())) { + // 자유 기록은 코스 미선택. courseId 가 와도 무시. + return null; + } + if (request.courseId() == null) { + throw new GeneralException(ErrorStatus.TRACKING_COURSE_ID_REQUIRED); + } + Course course = courseRepository.findById(request.courseId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND)); + if (course.getMountain() == null || !course.getMountain().getId().equals(mountain.getId())) { + throw new GeneralException(ErrorStatus.TRACKING_COURSE_MOUNTAIN_MISMATCH); + } + return course; + } +} diff --git a/src/main/resources/db/migration/V6__create_tracking_sessions.sql b/src/main/resources/db/migration/V6__create_tracking_sessions.sql new file mode 100644 index 00000000..5321e25e --- /dev/null +++ b/src/main/resources/db/migration/V6__create_tracking_sessions.sql @@ -0,0 +1,37 @@ +-- ===================================================================== +-- V6__create_tracking_sessions.sql +-- 목적: 트래킹 세션 도메인용 tracking_sessions 테이블 생성 +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS tracking_sessions +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + user_id bigint not null + constraint fk_tracking_sessions_user + references users, + mountain_id bigint not null + constraint fk_tracking_sessions_mountain + references mountains, + course_id bigint + constraint fk_tracking_sessions_course + references courses, + is_free_recording boolean not null, + status varchar(20) not null + constraint tracking_sessions_status_check + check (status in ('IN_PROGRESS', 'PAUSED', 'COMPLETED', 'ABANDONED')), + started_at timestamp(6) not null, + ended_at timestamp(6), + paused_at timestamp(6), + paused_seconds_total integer not null +); + +-- 유저별 활성 세션 조회 (existsByUser_IdAndStatusIn, findFirstByUser_IdAndStatusIn...) +CREATE INDEX IF NOT EXISTS idx_tracking_sessions_user_status + ON tracking_sessions (user_id, status); + +-- 24h 자동 만료 스케줄러 (status IN AND updated_at <) +CREATE INDEX IF NOT EXISTS idx_tracking_sessions_status_updated_at + ON tracking_sessions (status, updated_at);