-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 등산 기록 단건 상세 조회 API + 칼로리 계산 #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package com.semosan.api.domain.hiking.dto.response; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonInclude; | ||
| import com.fasterxml.jackson.annotation.JsonRawValue; | ||
| import com.semosan.api.domain.hiking.entity.HikingRecord; | ||
| import com.semosan.api.domain.tracking.entity.TrackingPhoto; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 등산 기록 단건 상세 응답. | ||
| * - track / altitudes: PostGIS / jsonb 를 그대로 통과 (응답에 raw JSON 으로 직렬화). 점이 0~1개면 null. | ||
| * - course: 자유 기록인 경우 null. | ||
| * - photos: 트래킹 중 업로드된 마일스톤 사진들 (시간/마일스톤 순). | ||
| */ | ||
| @JsonInclude(JsonInclude.Include.NON_NULL) | ||
| public record HikingRecordDetailResponse( | ||
| Long hikingRecordId, | ||
| MountainSummary mountain, | ||
| CourseSummary course, | ||
| Double distanceMeters, | ||
| Integer durationSeconds, | ||
| Double maxAltitudeMeters, | ||
| Double ascentMeters, | ||
| Double descentMeters, | ||
| Integer calories, | ||
| LocalDateTime startedAt, | ||
| LocalDateTime endedAt, | ||
| @JsonRawValue String track, | ||
| @JsonRawValue String altitudes, | ||
| List<PhotoMarker> photos | ||
| ) { | ||
| public record MountainSummary(Long id, String name) { | ||
| public static MountainSummary of(Long id, String name) { | ||
| return new MountainSummary(id, name); | ||
| } | ||
| } | ||
|
|
||
| public record CourseSummary(Long id, String name, String startName, String endName) { | ||
| public static CourseSummary of(Long id, String name, String startName, String endName) { | ||
| return new CourseSummary(id, name, startName, endName); | ||
| } | ||
| } | ||
|
|
||
| public record PhotoMarker( | ||
| Integer milestoneIndex, | ||
| Double lat, | ||
| Double lng, | ||
| String imageUrl, | ||
| LocalDateTime capturedAt | ||
| ) { | ||
| public static PhotoMarker from(TrackingPhoto photo) { | ||
| return new PhotoMarker( | ||
| photo.getMilestoneIndex(), | ||
| photo.getLat(), | ||
| photo.getLng(), | ||
| photo.getImageUrl(), | ||
| photo.getCapturedAt() | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| public static HikingRecordDetailResponse of( | ||
| HikingRecord record, | ||
| String track, | ||
| String altitudes, | ||
| List<TrackingPhoto> photos | ||
| ) { | ||
| return new HikingRecordDetailResponse( | ||
| record.getId(), | ||
| MountainSummary.of(record.getMountain().getId(), record.getMountain().getName()), | ||
| record.getCourse() == null | ||
| ? null | ||
| : CourseSummary.of( | ||
| record.getCourse().getId(), | ||
| record.getCourse().getName(), | ||
| record.getCourse().getStartName(), | ||
| record.getCourse().getEndName() | ||
| ), | ||
| record.getDistance(), | ||
| record.getDuration(), | ||
| record.getMaxAltitude(), | ||
| record.getAscent(), | ||
| record.getDescent(), | ||
| record.getCalories(), | ||
| record.getStartedAt(), | ||
| record.getEndedAt(), | ||
| track, | ||
| altitudes, | ||
| photos.stream().map(PhotoMarker::from).toList() | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
57 changes: 57 additions & 0 deletions
57
src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.semosan.api.domain.hiking.service; | ||
|
|
||
| /** | ||
| * 등산 칼로리 추정. | ||
| * | ||
| * 공식: kcal = MET × 체중(kg) × 시간(시간) | ||
| * - 체중이 등록돼 있지 않으면 한국 성인 평균치({@value #DEFAULT_WEIGHT_KG}kg) 로 대체한다. | ||
| * - 등산 강도(MET) 는 누적 상승고도(ascent) / 거리(distance) 비율로 단순 분기. | ||
| * Compendium of Physical Activities 2011 의 hiking 항목을 참고했다. | ||
| * | ||
| * 이 클래스는 정적 호출 전용이라 인스턴스화하지 않는다. | ||
| */ | ||
| public final class CalorieCalculator { | ||
|
|
||
| // TODO: 체중 미등록 시 한국 성인 평균치(65kg) 로 일괄 대체 중. 향후 사용자 성별·나이 기반 추정값 | ||
| // 또는 온보딩 단계에서 체중 입력 강제로 정확도 개선 검토. | ||
| static final double DEFAULT_WEIGHT_KG = 65.0; | ||
|
|
||
| private static final double MET_EASY = 6.0; | ||
| private static final double MET_MEDIUM = 7.5; | ||
| private static final double MET_HARD = 9.0; | ||
|
|
||
| private static final double MEDIUM_GRADE_RATIO = 0.05; | ||
| private static final double HARD_GRADE_RATIO = 0.10; | ||
|
|
||
| private CalorieCalculator() { | ||
| } | ||
|
|
||
| public static int calculate( | ||
| Double weightKg, | ||
| Double distanceMeters, | ||
| Double ascentMeters, | ||
| int durationSeconds | ||
| ) { | ||
| if (durationSeconds <= 0) { | ||
| return 0; | ||
| } | ||
| double effectiveWeight = (weightKg != null && weightKg > 0) ? weightKg : DEFAULT_WEIGHT_KG; | ||
| double met = decideMet(distanceMeters, ascentMeters); | ||
| double hours = durationSeconds / 3600.0; | ||
| return (int) Math.round(met * effectiveWeight * hours); | ||
| } | ||
|
|
||
| private static double decideMet(Double distanceMeters, Double ascentMeters) { | ||
| if (distanceMeters == null || distanceMeters <= 0 || ascentMeters == null || ascentMeters <= 0) { | ||
| return MET_EASY; | ||
| } | ||
| double ratio = ascentMeters / distanceMeters; | ||
| if (ratio < MEDIUM_GRADE_RATIO) { | ||
| return MET_EASY; | ||
| } | ||
| if (ratio < HARD_GRADE_RATIO) { | ||
| return MET_MEDIUM; | ||
| } | ||
| return MET_HARD; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
20 changes: 20 additions & 0 deletions
20
src/main/java/com/semosan/api/domain/tracking/repository/TrackingPointRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,31 @@ | ||
| package com.semosan.api.domain.tracking.repository; | ||
|
|
||
| import com.semosan.api.domain.tracking.entity.TrackingPoint; | ||
| import com.semosan.api.domain.tracking.repository.projection.TrackingPathProjection; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Query; | ||
| import org.springframework.data.repository.query.Param; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| public interface TrackingPointRepository extends JpaRepository<TrackingPoint, Long> { | ||
|
|
||
| List<TrackingPoint> findByTrackingSession_IdOrderByRecordedAtAsc(Long sessionId); | ||
|
|
||
| /** | ||
| * 세션의 GPS 점들을 시간순 LineString(GeoJSON) + 고도 배열(JSON) 로 묶어 한 번에 가져온다. | ||
| * 점이 0~1개면 ST_MakeLine 이 null 을 반환해 track 이 null 로 응답된다. | ||
| */ | ||
| @Query( | ||
| value = """ | ||
| SELECT | ||
| ST_AsGeoJSON(ST_MakeLine(location::geometry ORDER BY recorded_at))::text AS track, | ||
| json_agg(altitude ORDER BY recorded_at)::text AS altitudes | ||
| FROM tracking_points | ||
| WHERE tracking_session_id = :sessionId | ||
| """, | ||
| nativeQuery = true | ||
| ) | ||
| Optional<TrackingPathProjection> findTrackBySessionId(@Param("sessionId") Long sessionId); | ||
| } |
12 changes: 12 additions & 0 deletions
12
...in/java/com/semosan/api/domain/tracking/repository/projection/TrackingPathProjection.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.semosan.api.domain.tracking.repository.projection; | ||
|
|
||
| /** | ||
| * 트래킹 세션 한 건의 이동 경로 native query 결과. | ||
| * - track: PostGIS LineString 을 GeoJSON 으로 변환한 문자열. 점이 0~1개면 null. | ||
| * - altitudes: 점별 고도(jsonb 배열) 를 그대로 문자열로. 점이 0개면 "[null]" 등이 될 수 있어 호출자에서 처리한다. | ||
| * 응답 DTO 에서 @JsonRawValue 로 통과시킨다. | ||
| */ | ||
| public interface TrackingPathProjection { | ||
| String getTrack(); | ||
| String getAltitudes(); | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] 새롭게 추가된 칼로리 계산 로직(CalorieCalculator)과 등산 기록 상세 조회 API에 대한 단위 테스트가 누락되어 있습니다.\n\n### 이유\n칼로리 계산 공식(CalorieCalculator.calculate)은 체중 유무, 등산 강도(MET) 분기 등 다양한 경계 조건(Edge Case)을 포함하고 있으므로, 비즈니스 로직의 정확성을 보장하고 향후 변경 시 오작동을 방지하기 위해 철저한 단위 테스트가 필요합니다.\n\n### 해결 방안\nCalorieCalculatorTest를 추가하여 다음과 같은 시나리오들을 검증하는 테스트 코드를 작성하는 것을 권장합니다:\n- 체중이 없는 경우 기본 체중(65kg)으로 계산되는지 여부\n- 소요 시간이 0 이하일 때 0을 반환하는지 여부\n- 누적 상승고도와 거리 비율에 따라 MET 강도(EASY, MEDIUM, HARD)가 올바르게 판별되는지 여부