Skip to content

Commit 74bfb76

Browse files
authored
Merge pull request #160 from SEMOSAN/feat/#155-hiking-record-detail
feat: 등산 기록 단건 상세 조회 API + 칼로리 계산
2 parents 1695f70 + d5805af commit 74bfb76

10 files changed

Lines changed: 283 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public enum SuccessStatus implements BaseStatus {
5252
GET_HIKING_RECORD_SUMMARY_SUCCESS(HttpStatus.OK, "HIKING_200_2", "나의 등산 기록 요약 조회에 성공했습니다."),
5353
GET_HIKING_RECORD_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_3", "나의 등산 기록 목록 조회에 성공했습니다."),
5454
GET_HIKING_RECORD_LIST_BY_MOUNTAIN_SUCCESS(HttpStatus.OK, "HIKING_200_4", "특정 산의 나의 등산 기록 목록 조회에 성공했습니다."),
55+
GET_HIKING_RECORD_DETAIL_SUCCESS(HttpStatus.OK, "HIKING_200_5", "등산 기록 상세 조회에 성공했습니다."),
5556
CREATE_COURSE_DIFFICULTY_FEEDBACK_SUCCESS(HttpStatus.CREATED, "HIKING_201_1", "코스 난이도 피드백 저장에 성공했습니다."),
5657

5758
/**

src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
1010
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
1111
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
12+
import com.semosan.api.domain.hiking.dto.response.HikingRecordDetailResponse;
1213
import com.semosan.api.domain.hiking.service.HikingRecordService;
1314
import jakarta.validation.Valid;
1415
import lombok.RequiredArgsConstructor;
@@ -76,6 +77,17 @@ public ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> getUserHi
7677
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_SUMMARY_SUCCESS, response);
7778
}
7879

80+
// 등산 기록 단건 상세 조회 — 이동 경로(track) + 마일스톤 사진 포함.
81+
@GetMapping("/{hikingRecordId}")
82+
@Override
83+
public ResponseEntity<ApiResponse<HikingRecordDetailResponse>> getHikingRecordDetail(
84+
@AuthenticationPrincipal Long userId,
85+
@PathVariable Long hikingRecordId
86+
) {
87+
HikingRecordDetailResponse response = hikingRecordService.getHikingRecordDetail(userId, hikingRecordId);
88+
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_DETAIL_SUCCESS, response);
89+
}
90+
7991
// 코스 난이도 피드백을 저장합니다.
8092
@PostMapping("/{hikingRecordId}/difficulty-feedback")
8193
@Override

src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
88
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
99
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
10+
import com.semosan.api.domain.hiking.dto.response.HikingRecordDetailResponse;
1011
import jakarta.validation.Valid;
1112
import io.swagger.v3.oas.annotations.Operation;
1213
import io.swagger.v3.oas.annotations.Parameter;
@@ -94,6 +95,35 @@ ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> getUserHikingRec
9495
@AuthenticationPrincipal Long userId
9596
);
9697

98+
@Operation(
99+
summary = "등산 기록 단건 상세 조회",
100+
description = "본인이 참여한 등산 기록 1건의 상세 정보를 조회합니다. " +
101+
"기록 메타(코스/거리/소요시간/고도/칼로리) 와 함께 실제 이동 경로(GeoJSON LineString) · 점별 고도 배열 · 마일스톤 사진 마커를 함께 반환합니다. " +
102+
"트래킹 세션 없이 만들어진 기록(수동 입력) 의 경우 track/altitudes 는 null 로 내려갑니다."
103+
)
104+
@ApiResponses({
105+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
106+
responseCode = "200",
107+
description = "등산 기록 상세 조회 성공"
108+
),
109+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
110+
responseCode = "403",
111+
description = "본인이 참여한 등산 기록이 아님",
112+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
113+
),
114+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
115+
responseCode = "404",
116+
description = "등산 기록을 찾을 수 없음",
117+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
118+
)
119+
})
120+
ResponseEntity<ApiResponse<HikingRecordDetailResponse>> getHikingRecordDetail(
121+
@Parameter(hidden = true)
122+
@AuthenticationPrincipal Long userId,
123+
@Parameter(description = "등산 기록 ID", required = true)
124+
@PathVariable Long hikingRecordId
125+
);
126+
97127
@Operation(
98128
summary = "코스 난이도 피드백 저장",
99129
description = "코스 기반 등산 기록 1개에 대해 사용자가 체감한 난이도 비교 피드백을 저장합니다."
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.semosan.api.domain.hiking.dto.response;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import com.fasterxml.jackson.annotation.JsonRawValue;
5+
import com.semosan.api.domain.hiking.entity.HikingRecord;
6+
import com.semosan.api.domain.tracking.entity.TrackingPhoto;
7+
8+
import java.time.LocalDateTime;
9+
import java.util.List;
10+
11+
/**
12+
* 등산 기록 단건 상세 응답.
13+
* - track / altitudes: PostGIS / jsonb 를 그대로 통과 (응답에 raw JSON 으로 직렬화). 점이 0~1개면 null.
14+
* - course: 자유 기록인 경우 null.
15+
* - photos: 트래킹 중 업로드된 마일스톤 사진들 (시간/마일스톤 순).
16+
*/
17+
@JsonInclude(JsonInclude.Include.NON_NULL)
18+
public record HikingRecordDetailResponse(
19+
Long hikingRecordId,
20+
MountainSummary mountain,
21+
CourseSummary course,
22+
Double distanceMeters,
23+
Integer durationSeconds,
24+
Double maxAltitudeMeters,
25+
Double ascentMeters,
26+
Double descentMeters,
27+
Integer calories,
28+
LocalDateTime startedAt,
29+
LocalDateTime endedAt,
30+
@JsonRawValue String track,
31+
@JsonRawValue String altitudes,
32+
List<PhotoMarker> photos
33+
) {
34+
public record MountainSummary(Long id, String name) {
35+
public static MountainSummary of(Long id, String name) {
36+
return new MountainSummary(id, name);
37+
}
38+
}
39+
40+
public record CourseSummary(Long id, String name, String startName, String endName) {
41+
public static CourseSummary of(Long id, String name, String startName, String endName) {
42+
return new CourseSummary(id, name, startName, endName);
43+
}
44+
}
45+
46+
public record PhotoMarker(
47+
Integer milestoneIndex,
48+
Double lat,
49+
Double lng,
50+
String imageUrl,
51+
LocalDateTime capturedAt
52+
) {
53+
public static PhotoMarker from(TrackingPhoto photo) {
54+
return new PhotoMarker(
55+
photo.getMilestoneIndex(),
56+
photo.getLat(),
57+
photo.getLng(),
58+
photo.getImageUrl(),
59+
photo.getCapturedAt()
60+
);
61+
}
62+
}
63+
64+
public static HikingRecordDetailResponse of(
65+
HikingRecord record,
66+
String track,
67+
String altitudes,
68+
List<TrackingPhoto> photos
69+
) {
70+
return new HikingRecordDetailResponse(
71+
record.getId(),
72+
MountainSummary.of(record.getMountain().getId(), record.getMountain().getName()),
73+
record.getCourse() == null
74+
? null
75+
: CourseSummary.of(
76+
record.getCourse().getId(),
77+
record.getCourse().getName(),
78+
record.getCourse().getStartName(),
79+
record.getCourse().getEndName()
80+
),
81+
record.getDistance(),
82+
record.getDuration(),
83+
record.getMaxAltitude(),
84+
record.getAscent(),
85+
record.getDescent(),
86+
record.getCalories(),
87+
record.getStartedAt(),
88+
record.getEndedAt(),
89+
track,
90+
altitudes,
91+
photos.stream().map(PhotoMarker::from).toList()
92+
);
93+
}
94+
}

src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.semosan.api.domain.hiking.entity;
22

33
import com.semosan.api.common.base.BaseEntity;
4+
import com.semosan.api.domain.hiking.service.CalorieCalculator;
45
import com.semosan.api.domain.mountain.entity.Course;
56
import com.semosan.api.domain.mountain.entity.Mountain;
67
import com.semosan.api.domain.tracking.entity.TrackingSession;
@@ -94,13 +95,17 @@ public static HikingRecord fromTrackingSession(
9495
Double descent
9596
) {
9697
int duration = computeDurationSeconds(session);
98+
// 사용자 체중 + 코스 강도(ascent/distance) 기반 MET 공식.
99+
// 체중 미등록 시 CalorieCalculator 내부 기본값(65kg) 으로 대체된다.
100+
Double weight = session.getUser() == null ? null : session.getUser().getWeight();
101+
int calories = CalorieCalculator.calculate(weight, distance, ascent, duration);
97102
return HikingRecord.builder()
98103
.mountain(session.getMountain())
99104
.course(session.getCourse())
100105
.trackingSession(session)
101106
.duration(duration)
102107
.maxAltitude(maxAltitude == null ? 0.0 : maxAltitude)
103-
.calories(0) // TODO: 칼로리 산정 공식 도입 시 교체
108+
.calories(calories)
104109
.distance(distance)
105110
.ascent(ascent)
106111
.descent(descent)

src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,22 @@
1111
import org.springframework.data.repository.query.Param;
1212

1313
import java.util.List;
14+
import java.util.Optional;
1415

1516
public interface HikingRecordRepository extends JpaRepository<HikingRecord, Long> {
1617

18+
/**
19+
* 단건 상세 조회용. Mountain 은 INNER, Course 는 LEFT JOIN FETCH(자유기록은 null) 로 한 쿼리에 묶는다.
20+
* 응답 조립 시 mountain.name / course.name 접근으로 발생하는 추가 SELECT 를 막는다.
21+
*/
22+
@Query("""
23+
SELECT hr FROM HikingRecord hr
24+
JOIN FETCH hr.mountain
25+
LEFT JOIN FETCH hr.course
26+
WHERE hr.id = :id
27+
""")
28+
Optional<HikingRecord> findWithMountainAndCourseById(@Param("id") Long id);
29+
1730
@Query(
1831
value = """
1932
SELECT hm.hiking_record_id
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.semosan.api.domain.hiking.service;
2+
3+
/**
4+
* 등산 칼로리 추정.
5+
*
6+
* 공식: kcal = MET × 체중(kg) × 시간(시간)
7+
* - 체중이 등록돼 있지 않으면 한국 성인 평균치({@value #DEFAULT_WEIGHT_KG}kg) 로 대체한다.
8+
* - 등산 강도(MET) 는 누적 상승고도(ascent) / 거리(distance) 비율로 단순 분기.
9+
* Compendium of Physical Activities 2011 의 hiking 항목을 참고했다.
10+
*
11+
* 이 클래스는 정적 호출 전용이라 인스턴스화하지 않는다.
12+
*/
13+
public final class CalorieCalculator {
14+
15+
// TODO: 체중 미등록 시 한국 성인 평균치(65kg) 로 일괄 대체 중. 향후 사용자 성별·나이 기반 추정값
16+
// 또는 온보딩 단계에서 체중 입력 강제로 정확도 개선 검토.
17+
static final double DEFAULT_WEIGHT_KG = 65.0;
18+
19+
private static final double MET_EASY = 6.0;
20+
private static final double MET_MEDIUM = 7.5;
21+
private static final double MET_HARD = 9.0;
22+
23+
private static final double MEDIUM_GRADE_RATIO = 0.05;
24+
private static final double HARD_GRADE_RATIO = 0.10;
25+
26+
private CalorieCalculator() {
27+
}
28+
29+
public static int calculate(
30+
Double weightKg,
31+
Double distanceMeters,
32+
Double ascentMeters,
33+
int durationSeconds
34+
) {
35+
if (durationSeconds <= 0) {
36+
return 0;
37+
}
38+
double effectiveWeight = (weightKg != null && weightKg > 0) ? weightKg : DEFAULT_WEIGHT_KG;
39+
double met = decideMet(distanceMeters, ascentMeters);
40+
double hours = durationSeconds / 3600.0;
41+
return (int) Math.round(met * effectiveWeight * hours);
42+
}
43+
44+
private static double decideMet(Double distanceMeters, Double ascentMeters) {
45+
if (distanceMeters == null || distanceMeters <= 0 || ascentMeters == null || ascentMeters <= 0) {
46+
return MET_EASY;
47+
}
48+
double ratio = ascentMeters / distanceMeters;
49+
if (ratio < MEDIUM_GRADE_RATIO) {
50+
return MET_EASY;
51+
}
52+
if (ratio < HARD_GRADE_RATIO) {
53+
return MET_MEDIUM;
54+
}
55+
return MET_HARD;
56+
}
57+
}

src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
88
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
99
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
10+
import com.semosan.api.domain.hiking.dto.response.HikingRecordDetailResponse;
1011
import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback;
1112
import com.semosan.api.domain.hiking.entity.HikingRecord;
1213
import com.semosan.api.domain.hiking.repository.CourseDifficultyFeedbackRepository;
1314
import com.semosan.api.domain.hiking.repository.HikingMemberRepository;
1415
import com.semosan.api.domain.hiking.repository.HikingRecordRepository;
1516
import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordSummaryProjection;
1617
import com.semosan.api.domain.mountain.repository.MountainRepository;
18+
import com.semosan.api.domain.tracking.entity.TrackingPhoto;
19+
import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository;
20+
import com.semosan.api.domain.tracking.repository.TrackingPointRepository;
21+
import com.semosan.api.domain.tracking.repository.projection.TrackingPathProjection;
1722
import com.semosan.api.domain.user.entity.User;
1823
import com.semosan.api.domain.user.service.UserReader;
1924
import lombok.RequiredArgsConstructor;
@@ -24,6 +29,9 @@
2429
import org.springframework.stereotype.Service;
2530
import org.springframework.transaction.annotation.Transactional;
2631

32+
import java.util.List;
33+
import java.util.Optional;
34+
2735
@Service
2836
@RequiredArgsConstructor
2937
public class HikingRecordService {
@@ -36,6 +44,8 @@ public class HikingRecordService {
3644
private final MountainRepository mountainRepository;
3745
private final HikingMemberRepository hikingMemberRepository;
3846
private final CourseDifficultyFeedbackRepository courseDifficultyFeedbackRepository;
47+
private final TrackingPointRepository trackingPointRepository;
48+
private final TrackingPhotoRepository trackingPhotoRepository;
3949

4050
// 유저가 다녀온 산 목록을 산 단위로 묶어 조회합니다.
4151
@Transactional(readOnly = true)
@@ -68,6 +78,34 @@ public Page<GetUserHikingRecordResponse> getUserHikingRecordsByMountainId(
6878
.map(GetUserHikingRecordResponse::from);
6979
}
7080

81+
// 등산 기록 단건 상세를 조회합니다. (이동 경로 + 마일스톤 사진 포함)
82+
@Transactional(readOnly = true)
83+
public HikingRecordDetailResponse getHikingRecordDetail(Long userId, Long hikingRecordId) {
84+
User user = userReader.findCompletedOnboardingUserById(userId);
85+
// Mountain / Course 를 fetch join 으로 함께 가져와 응답 조립 시 추가 SELECT 를 막는다.
86+
HikingRecord record = hikingRecordRepository.findWithMountainAndCourseById(hikingRecordId)
87+
.orElseThrow(() -> new GeneralException(ErrorStatus.HIKING_RECORD_NOT_FOUND));
88+
89+
if (!hikingMemberRepository.existsByHikingRecordAndUser(record, user)) {
90+
throw new GeneralException(ErrorStatus.HIKING_RECORD_FORBIDDEN);
91+
}
92+
93+
String track = null;
94+
String altitudes = null;
95+
List<TrackingPhoto> photos = List.of();
96+
if (record.getTrackingSession() != null) {
97+
Long sessionId = record.getTrackingSession().getId();
98+
Optional<TrackingPathProjection> path = trackingPointRepository.findTrackBySessionId(sessionId);
99+
if (path.isPresent() && path.get().getTrack() != null) {
100+
track = path.get().getTrack();
101+
altitudes = path.get().getAltitudes();
102+
}
103+
photos = trackingPhotoRepository.findByTrackingSession_IdOrderByMilestoneIndexAsc(sessionId);
104+
}
105+
106+
return HikingRecordDetailResponse.of(record, track, altitudes, photos);
107+
}
108+
71109
// 유저의 등산 기록 요약 정보를 조회합니다.
72110
@Transactional(readOnly = true)
73111
public GetUserHikingRecordSummaryResponse getUserHikingRecordSummary(Long userId) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
11
package com.semosan.api.domain.tracking.repository;
22

33
import com.semosan.api.domain.tracking.entity.TrackingPoint;
4+
import com.semosan.api.domain.tracking.repository.projection.TrackingPathProjection;
45
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
58

69
import java.util.List;
10+
import java.util.Optional;
711

812
public interface TrackingPointRepository extends JpaRepository<TrackingPoint, Long> {
913

1014
List<TrackingPoint> findByTrackingSession_IdOrderByRecordedAtAsc(Long sessionId);
15+
16+
/**
17+
* 세션의 GPS 점들을 시간순 LineString(GeoJSON) + 고도 배열(JSON) 로 묶어 한 번에 가져온다.
18+
* 점이 0~1개면 ST_MakeLine 이 null 을 반환해 track 이 null 로 응답된다.
19+
*/
20+
@Query(
21+
value = """
22+
SELECT
23+
ST_AsGeoJSON(ST_MakeLine(location::geometry ORDER BY recorded_at))::text AS track,
24+
json_agg(altitude ORDER BY recorded_at)::text AS altitudes
25+
FROM tracking_points
26+
WHERE tracking_session_id = :sessionId
27+
""",
28+
nativeQuery = true
29+
)
30+
Optional<TrackingPathProjection> findTrackBySessionId(@Param("sessionId") Long sessionId);
1131
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.semosan.api.domain.tracking.repository.projection;
2+
3+
/**
4+
* 트래킹 세션 한 건의 이동 경로 native query 결과.
5+
* - track: PostGIS LineString 을 GeoJSON 으로 변환한 문자열. 점이 0~1개면 null.
6+
* - altitudes: 점별 고도(jsonb 배열) 를 그대로 문자열로. 점이 0개면 "[null]" 등이 될 수 있어 호출자에서 처리한다.
7+
* 응답 DTO 에서 @JsonRawValue 로 통과시킨다.
8+
*/
9+
public interface TrackingPathProjection {
10+
String getTrack();
11+
String getAltitudes();
12+
}

0 commit comments

Comments
 (0)