Skip to content

Commit aa62d48

Browse files
committed
feat: 트래킹 세션 관리 API 및 24h 자동 만료 스케줄러 추가
1 parent 2512a3c commit aa62d48

8 files changed

Lines changed: 412 additions & 1 deletion

File tree

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/status/SuccessStatus.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,16 @@ public enum SuccessStatus implements BaseStatus {
4949
GET_HIKING_RECORD_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_3", "나의 등산 기록 목록 조회에 성공했습니다."),
5050

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

5663
/**
5764
* Image
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+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.semosan.api.domain.tracking.scheduler;
2+
3+
import com.semosan.api.domain.tracking.entity.TrackingSession;
4+
import com.semosan.api.domain.tracking.enums.TrackingSessionStatus;
5+
import com.semosan.api.domain.tracking.repository.TrackingSessionRepository;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
import java.time.Duration;
13+
import java.time.LocalDateTime;
14+
import java.util.EnumSet;
15+
import java.util.List;
16+
import java.util.Set;
17+
18+
/**
19+
* 24h 동안 어떤 업데이트도 없는 활성(IN_PROGRESS/PAUSED) 세션을 자동으로 ABANDONED 처리한다.
20+
* - 사용자가 앱을 닫고 잊거나, 네트워크 단절로 종료 신호가 안 들어온 케이스 정리.
21+
* - 매 1시간마다 실행.
22+
*
23+
* TODO: 만료 정책(24h) 변경 시 environment property 화. 기록 정리 시 알림 발송 여부 검토.
24+
*/
25+
@Slf4j
26+
@Component
27+
@RequiredArgsConstructor
28+
public class TrackingSessionExpiryScheduler {
29+
30+
private static final Set<TrackingSessionStatus> ACTIVE_STATES =
31+
EnumSet.of(TrackingSessionStatus.IN_PROGRESS, TrackingSessionStatus.PAUSED);
32+
private static final Duration EXPIRY_THRESHOLD = Duration.ofHours(24);
33+
34+
private final TrackingSessionRepository trackingSessionRepository;
35+
36+
@Scheduled(cron = "0 0 * * * *") // 매 정시 0분 (1시간 간격)
37+
@Transactional
38+
public void expireStaleSessions() {
39+
LocalDateTime cutoff = LocalDateTime.now().minus(EXPIRY_THRESHOLD);
40+
List<TrackingSession> stale = trackingSessionRepository.findStaleActiveSessions(ACTIVE_STATES, cutoff);
41+
if (stale.isEmpty()) {
42+
return;
43+
}
44+
for (TrackingSession session : stale) {
45+
session.abandon();
46+
}
47+
log.info("Expired {} stale tracking sessions (cutoff={})", stale.size(), cutoff);
48+
}
49+
}

0 commit comments

Comments
 (0)