Skip to content

Commit 5913233

Browse files
authored
Merge pull request #52 from SEMOSAN/feat/#18-tracking-session
[Feat]#18 트래킹 세션
2 parents 340ada9 + 08ff10d commit 5913233

16 files changed

Lines changed: 680 additions & 5 deletions

k8s/kustomization.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ resources:
1414
- minio/service.yaml
1515
images:
1616
- name: ghcr.io/semosan/semosan_be
17-
newTag: 14e02fc87a6a73e49418a399891e97c2b839b3df
17+
newTag: fb0eb2823ad7e4ee955bd3941443be46e88a22cf

src/main/java/com/semosan/api/ApiApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import org.springframework.boot.autoconfigure.SpringBootApplication;
66
import org.springframework.boot.context.properties.EnableConfigurationProperties;
77
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
8+
import org.springframework.scheduling.annotation.EnableScheduling;
89

910
@EnableJpaAuditing
11+
@EnableScheduling
1012
@SpringBootApplication
1113
@EnableConfigurationProperties(KakaoProperties.class)
1214
public class ApiApplication {

src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.semosan.api.common.base.BaseStatus;
44
import com.semosan.api.common.status.ErrorStatus;
55
import com.semosan.api.common.response.ApiResponse;
6+
import jakarta.validation.ConstraintViolationException;
67
import lombok.extern.slf4j.Slf4j;
78
import org.springframework.http.HttpHeaders;
89
import org.springframework.http.HttpStatusCode;
@@ -11,6 +12,7 @@
1112
import org.springframework.web.bind.annotation.ExceptionHandler;
1213
import org.springframework.web.bind.annotation.RestControllerAdvice;
1314
import org.springframework.web.context.request.WebRequest;
15+
import org.springframework.web.method.annotation.HandlerMethodValidationException;
1416
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
1517

1618
@Slf4j
@@ -39,6 +41,32 @@ public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(
3941
return ApiResponse.error(ErrorStatus.BAD_REQUEST);
4042
}
4143

44+
// @Validated 컨트롤러의 @RequestParam/@PathVariable 단순 타입 검증 실패 (Spring 6 MethodValidationInterceptor 경로)
45+
@ExceptionHandler(ConstraintViolationException.class)
46+
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
47+
ConstraintViolationException e
48+
) {
49+
String message = e.getConstraintViolations().stream()
50+
.findFirst()
51+
.map(v -> v.getPropertyPath().toString() + ": " + v.getMessage())
52+
.orElse(ErrorStatus.BAD_REQUEST.getMessage());
53+
log.warn("[*] ConstraintViolationException : {}", message);
54+
return ApiResponse.error(ErrorStatus.BAD_REQUEST, message);
55+
}
56+
57+
// Spring 6.1+ HandlerMethodValidationException (컨트롤러 메서드 파라미터 검증 실패 표준 경로)
58+
@ExceptionHandler(HandlerMethodValidationException.class)
59+
public ResponseEntity<ApiResponse<Void>> handleHandlerMethodValidation(
60+
HandlerMethodValidationException e
61+
) {
62+
String message = e.getAllErrors().stream()
63+
.findFirst()
64+
.map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : ErrorStatus.BAD_REQUEST.getMessage())
65+
.orElse(ErrorStatus.BAD_REQUEST.getMessage());
66+
log.warn("[*] HandlerMethodValidationException : {}", message);
67+
return ApiResponse.error(ErrorStatus.BAD_REQUEST, message);
68+
}
69+
4270
// null 참조로 발생한 서버 오류를 500 에러로 응답
4371
@ExceptionHandler(NullPointerException.class)
4472
public ResponseEntity<ApiResponse<Void>> handleNullPointerException(

src/main/java/com/semosan/api/common/status/ErrorStatus.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ public enum ErrorStatus implements BaseStatus {
105105
HIKING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "HIKE_404_1", "등산 기록을 찾을 수 없습니다."),
106106
HIKING_RECORD_FORBIDDEN(HttpStatus.FORBIDDEN, "HIKE_403_1", "본인이 참여한 등산 기록만 공유할 수 있습니다."),
107107

108+
/**
109+
* Tracking (트래킹 세션)
110+
*/
111+
TRACKING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_1", "트래킹 세션을 찾을 수 없습니다."),
112+
TRACKING_SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "TRK_403_1", "본인의 트래킹 세션만 조작할 수 있습니다."),
113+
TRACKING_SESSION_ALREADY_IN_PROGRESS(HttpStatus.CONFLICT, "TRK_409_1", "이미 진행 중인 트래킹 세션이 있습니다."),
114+
TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."),
115+
TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."),
116+
TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."),
117+
COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."),
118+
108119
/**
109120
* Post (게시글 공통)
110121
*/

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,16 @@ public enum SuccessStatus implements BaseStatus {
5252
GET_HIKING_RECORD_LIST_BY_MOUNTAIN_SUCCESS(HttpStatus.OK, "HIKING_200_4", "특정 산의 나의 등산 기록 목록 조회에 성공했습니다."),
5353

5454
/**
55-
* Tracking
55+
* Tracking — 진입 화면 (#45) & 세션 (#18)
5656
*/
5757
TRACKING_NEAREST_MOUNTAIN_SUCCESS(HttpStatus.OK, "TRK_200_1", "현재 위치 기준 가까운 산과 코스 조회에 성공했습니다."),
58+
TRACKING_SESSION_CREATE_SUCCESS(HttpStatus.CREATED, "TRK_201_1", "트래킹 세션이 시작되었습니다."),
59+
TRACKING_SESSION_GET_ACTIVE_SUCCESS(HttpStatus.OK, "TRK_200_2", "현재 진행 중인 트래킹 세션 조회에 성공했습니다."),
60+
TRACKING_SESSION_GET_SUCCESS(HttpStatus.OK, "TRK_200_3", "트래킹 세션 상세 조회에 성공했습니다."),
61+
TRACKING_SESSION_PAUSE_SUCCESS(HttpStatus.OK, "TRK_200_4", "트래킹 세션을 일시정지했습니다."),
62+
TRACKING_SESSION_RESUME_SUCCESS(HttpStatus.OK, "TRK_200_5", "트래킹 세션을 재개했습니다."),
63+
TRACKING_SESSION_COMPLETE_SUCCESS(HttpStatus.OK, "TRK_200_6", "트래킹 세션을 종료했습니다."),
64+
TRACKING_SESSION_ABANDON_SUCCESS(HttpStatus.OK, "TRK_200_7", "트래킹 세션을 포기 처리했습니다."),
5865

5966
/**
6067
* Image

src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import com.semosan.api.domain.tracking.controller.docs.TrackingControllerDocs;
66
import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse;
77
import com.semosan.api.domain.tracking.service.TrackingService;
8+
import jakarta.validation.constraints.Max;
9+
import jakarta.validation.constraints.Min;
810
import lombok.RequiredArgsConstructor;
911
import org.springframework.http.ResponseEntity;
1012
import org.springframework.security.core.annotation.AuthenticationPrincipal;
13+
import org.springframework.validation.annotation.Validated;
1114
import org.springframework.web.bind.annotation.GetMapping;
1215
import org.springframework.web.bind.annotation.RequestMapping;
1316
import org.springframework.web.bind.annotation.RequestParam;
@@ -16,21 +19,23 @@
1619
@RestController
1720
@RequestMapping("/api/tracking")
1821
@RequiredArgsConstructor
22+
@Validated
1923
public class TrackingController implements TrackingControllerDocs {
2024

2125
private final TrackingService trackingService;
2226

2327
/**
2428
* 트래킹 진입 화면에서 호출.
2529
* 프론트가 디바이스 GPS 좌표(lat, lng)를 보내면, 가장 가까운 산과 그 산의 코스 목록을 반환한다.
26-
* 인증 필수 — 트래킹은 로그인 사용자만 시작 가능.
30+
* 인증 필수 — 트래킹은 로그인 사용자만 시작 가능 (userId 는 Spring Security 인증 통과 강제용).
31+
* 사용자별 개인화는 추후 도입 시 service 로 userId 를 넘겨 활용.
2732
*/
2833
@GetMapping("/nearby-mountain")
2934
@Override
3035
public ResponseEntity<ApiResponse<NearbyMountainResponse>> getNearbyMountain(
3136
@AuthenticationPrincipal Long userId,
32-
@RequestParam Double lat,
33-
@RequestParam Double lng
37+
@RequestParam @Min(-90) @Max(90) Double lat,
38+
@RequestParam @Min(-180) @Max(180) Double lng
3439
) {
3540
NearbyMountainResponse response = trackingService.getNearbyMountain(userId, lat, lng);
3641
return ApiResponse.success(SuccessStatus.TRACKING_NEAREST_MOUNTAIN_SUCCESS, response);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.semosan.api.domain.tracking.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.status.SuccessStatus;
5+
import com.semosan.api.domain.tracking.controller.docs.TrackingSessionControllerDocs;
6+
import com.semosan.api.domain.tracking.dto.request.CreateTrackingSessionRequest;
7+
import com.semosan.api.domain.tracking.dto.response.TrackingSessionResponse;
8+
import com.semosan.api.domain.tracking.service.TrackingSessionService;
9+
import jakarta.validation.Valid;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
@RestController
16+
@RequestMapping("/api/tracking/sessions")
17+
@RequiredArgsConstructor
18+
public class TrackingSessionController implements TrackingSessionControllerDocs {
19+
20+
private final TrackingSessionService trackingSessionService;
21+
22+
@PostMapping
23+
@Override
24+
public ResponseEntity<ApiResponse<TrackingSessionResponse>> createSession(
25+
@AuthenticationPrincipal Long userId,
26+
@Valid @RequestBody CreateTrackingSessionRequest request
27+
) {
28+
TrackingSessionResponse response = trackingSessionService.create(userId, request);
29+
return ApiResponse.success(SuccessStatus.TRACKING_SESSION_CREATE_SUCCESS, response);
30+
}
31+
32+
/**
33+
* 앱 재진입 시 호출. 진행 중 세션이 없으면 data 가 비어 응답된다 (NON_NULL 직렬화).
34+
*/
35+
@GetMapping("/me/active")
36+
@Override
37+
public ResponseEntity<ApiResponse<TrackingSessionResponse>> getActiveSession(
38+
@AuthenticationPrincipal Long userId
39+
) {
40+
TrackingSessionResponse response = trackingSessionService.getActive(userId).orElse(null);
41+
return ApiResponse.success(SuccessStatus.TRACKING_SESSION_GET_ACTIVE_SUCCESS, response);
42+
}
43+
44+
@GetMapping("/{sessionId}")
45+
@Override
46+
public ResponseEntity<ApiResponse<TrackingSessionResponse>> getSession(
47+
@AuthenticationPrincipal Long userId,
48+
@PathVariable Long sessionId
49+
) {
50+
TrackingSessionResponse response = trackingSessionService.get(userId, sessionId);
51+
return ApiResponse.success(SuccessStatus.TRACKING_SESSION_GET_SUCCESS, response);
52+
}
53+
54+
@PostMapping("/{sessionId}/pause")
55+
@Override
56+
public ResponseEntity<ApiResponse<TrackingSessionResponse>> pauseSession(
57+
@AuthenticationPrincipal Long userId,
58+
@PathVariable Long sessionId
59+
) {
60+
TrackingSessionResponse response = trackingSessionService.pause(userId, sessionId);
61+
return ApiResponse.success(SuccessStatus.TRACKING_SESSION_PAUSE_SUCCESS, response);
62+
}
63+
64+
@PostMapping("/{sessionId}/resume")
65+
@Override
66+
public ResponseEntity<ApiResponse<TrackingSessionResponse>> resumeSession(
67+
@AuthenticationPrincipal Long userId,
68+
@PathVariable Long sessionId
69+
) {
70+
TrackingSessionResponse response = trackingSessionService.resume(userId, sessionId);
71+
return ApiResponse.success(SuccessStatus.TRACKING_SESSION_RESUME_SUCCESS, response);
72+
}
73+
74+
@PostMapping("/{sessionId}/complete")
75+
@Override
76+
public ResponseEntity<ApiResponse<TrackingSessionResponse>> completeSession(
77+
@AuthenticationPrincipal Long userId,
78+
@PathVariable Long sessionId
79+
) {
80+
TrackingSessionResponse response = trackingSessionService.complete(userId, sessionId);
81+
return ApiResponse.success(SuccessStatus.TRACKING_SESSION_COMPLETE_SUCCESS, response);
82+
}
83+
84+
@PostMapping("/{sessionId}/abandon")
85+
@Override
86+
public ResponseEntity<ApiResponse<TrackingSessionResponse>> abandonSession(
87+
@AuthenticationPrincipal Long userId,
88+
@PathVariable Long sessionId
89+
) {
90+
TrackingSessionResponse response = trackingSessionService.abandon(userId, sessionId);
91+
return ApiResponse.success(SuccessStatus.TRACKING_SESSION_ABANDON_SUCCESS, response);
92+
}
93+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.semosan.api.domain.tracking.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.domain.tracking.dto.request.CreateTrackingSessionRequest;
5+
import com.semosan.api.domain.tracking.dto.response.TrackingSessionResponse;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.Parameter;
8+
import io.swagger.v3.oas.annotations.media.Content;
9+
import io.swagger.v3.oas.annotations.media.Schema;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
import jakarta.validation.Valid;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.web.bind.annotation.PathVariable;
16+
import org.springframework.web.bind.annotation.RequestBody;
17+
18+
@Tag(name = "Tracking Session", description = "트래킹 세션 관리 API")
19+
public interface TrackingSessionControllerDocs {
20+
21+
@Operation(
22+
summary = "트래킹 세션 시작",
23+
description = "산/코스 정보로 새 트래킹 세션을 생성합니다. 자유 기록(isFreeRecording=true)이면 courseId 는 무시됩니다. "
24+
+ "유저당 진행 중 세션이 이미 있으면 409 응답."
25+
)
26+
@ApiResponses({
27+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "세션 생성 성공"),
28+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "산 또는 코스 없음",
29+
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
30+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 진행 중인 세션 존재",
31+
content = @Content(schema = @Schema(implementation = ApiResponse.class)))
32+
})
33+
ResponseEntity<ApiResponse<TrackingSessionResponse>> createSession(
34+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
35+
@Valid @RequestBody CreateTrackingSessionRequest request
36+
);
37+
38+
@Operation(summary = "현재 진행 중 트래킹 세션 조회",
39+
description = "앱 재진입 시 호출. 진행 중(IN_PROGRESS/PAUSED) 세션이 없으면 data 가 비어 응답됩니다.")
40+
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공")})
41+
ResponseEntity<ApiResponse<TrackingSessionResponse>> getActiveSession(
42+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId
43+
);
44+
45+
@Operation(summary = "트래킹 세션 상세 조회", description = "본인 소유 세션만 조회 가능합니다.")
46+
@ApiResponses({
47+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
48+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인 세션 아님",
49+
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
50+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "세션 없음",
51+
content = @Content(schema = @Schema(implementation = ApiResponse.class)))
52+
})
53+
ResponseEntity<ApiResponse<TrackingSessionResponse>> getSession(
54+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
55+
@Parameter(description = "세션 ID", required = true) @PathVariable Long sessionId
56+
);
57+
58+
@Operation(summary = "트래킹 세션 일시정지", description = "IN_PROGRESS 상태에서만 가능. 점 수집은 멈추고 duration 에서 제외됩니다.")
59+
@ApiResponses({
60+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "일시정지 성공"),
61+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "현재 상태에서 일시정지 불가",
62+
content = @Content(schema = @Schema(implementation = ApiResponse.class)))
63+
})
64+
ResponseEntity<ApiResponse<TrackingSessionResponse>> pauseSession(
65+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
66+
@PathVariable Long sessionId
67+
);
68+
69+
@Operation(summary = "트래킹 세션 재개", description = "PAUSED 상태에서만 가능. 일시정지 누적 시간이 합산됩니다.")
70+
ResponseEntity<ApiResponse<TrackingSessionResponse>> resumeSession(
71+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
72+
@PathVariable Long sessionId
73+
);
74+
75+
@Operation(summary = "트래킹 세션 정상 종료",
76+
description = "세션 상태/시각만 마감합니다. 실제 HikingRecord 변환은 #20 에서 구현됩니다.")
77+
ResponseEntity<ApiResponse<TrackingSessionResponse>> completeSession(
78+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
79+
@PathVariable Long sessionId
80+
);
81+
82+
@Operation(summary = "트래킹 세션 포기", description = "기록 없이 세션을 ABANDONED 처리합니다.")
83+
ResponseEntity<ApiResponse<TrackingSessionResponse>> abandonSession(
84+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
85+
@PathVariable Long sessionId
86+
);
87+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.semosan.api.domain.tracking.dto.request;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
5+
public record CreateTrackingSessionRequest(
6+
@NotNull(message = "산 ID는 필수입니다.")
7+
Long mountainId,
8+
9+
/** 자유 기록(코스 미선택) 인 경우 null */
10+
Long courseId,
11+
12+
@NotNull(message = "자유 기록 여부는 필수입니다.")
13+
Boolean isFreeRecording
14+
) {
15+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.semosan.api.domain.tracking.dto.response;
2+
3+
import com.semosan.api.domain.tracking.entity.TrackingSession;
4+
import com.semosan.api.domain.tracking.enums.TrackingSessionStatus;
5+
6+
import java.time.LocalDateTime;
7+
8+
public record TrackingSessionResponse(
9+
Long sessionId,
10+
Long userId,
11+
Long mountainId,
12+
String mountainName,
13+
Long courseId,
14+
String courseName,
15+
Boolean isFreeRecording,
16+
TrackingSessionStatus status,
17+
LocalDateTime startedAt,
18+
LocalDateTime endedAt,
19+
LocalDateTime pausedAt,
20+
Integer pausedSecondsTotal
21+
) {
22+
23+
public static TrackingSessionResponse from(TrackingSession session) {
24+
return new TrackingSessionResponse(
25+
session.getId(),
26+
session.getUser().getId(),
27+
session.getMountain().getId(),
28+
session.getMountain().getName(),
29+
session.getCourse() != null ? session.getCourse().getId() : null,
30+
session.getCourse() != null ? session.getCourse().getName() : null,
31+
session.getIsFreeRecording(),
32+
session.getStatus(),
33+
session.getStartedAt(),
34+
session.getEndedAt(),
35+
session.getPausedAt(),
36+
session.getPausedSecondsTotal()
37+
);
38+
}
39+
}

0 commit comments

Comments
 (0)