From f619c3a17a60ecb92d93a0e9f1d54ca006c44b5c Mon Sep 17 00:00:00 2001 From: JangInho Date: Fri, 29 May 2026 23:00:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=93=B1=EC=82=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=8B=A8=EA=B1=B4=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20+=20=EC=B9=BC=EB=A1=9C=EB=A6=AC=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/hiking-records/{hikingRecordId} 추가. 본인이 참여한 등산 기록의 메타(코스/거리/소요시간/고도/칼로리) 와 함께 PostGIS 의 tracking_points 를 GeoJSON LineString + 점별 고도 배열로 묶고 마일스톤 사진 마커도 함께 반환. 칼로리는 사용자 체중 + 코스 강도(ascent/distance) 기반 MET 공식으로 HikingRecord.fromTrackingSession 시점에 1회 계산해 저장. 체중 미등록 시 65kg 기본값으로 대체. --- .../api/common/status/SuccessStatus.java | 1 + .../controller/HikingRecordController.java | 12 +++ .../docs/HikingRecordControllerDocs.java | 30 ++++++ .../response/HikingRecordDetailResponse.java | 94 +++++++++++++++++++ .../domain/hiking/entity/HikingRecord.java | 7 +- .../hiking/service/CalorieCalculator.java | 55 +++++++++++ .../hiking/service/HikingRecordService.java | 37 ++++++++ .../repository/TrackingPointRepository.java | 20 ++++ .../projection/TrackingPathProjection.java | 12 +++ 9 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java create mode 100644 src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java create mode 100644 src/main/java/com/semosan/api/domain/tracking/repository/projection/TrackingPathProjection.java 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 a25334e9..f4f3f894 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -52,6 +52,7 @@ public enum SuccessStatus implements BaseStatus { GET_HIKING_RECORD_SUMMARY_SUCCESS(HttpStatus.OK, "HIKING_200_2", "나의 등산 기록 요약 조회에 성공했습니다."), GET_HIKING_RECORD_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_3", "나의 등산 기록 목록 조회에 성공했습니다."), GET_HIKING_RECORD_LIST_BY_MOUNTAIN_SUCCESS(HttpStatus.OK, "HIKING_200_4", "특정 산의 나의 등산 기록 목록 조회에 성공했습니다."), + GET_HIKING_RECORD_DETAIL_SUCCESS(HttpStatus.OK, "HIKING_200_5", "등산 기록 상세 조회에 성공했습니다."), CREATE_COURSE_DIFFICULTY_FEEDBACK_SUCCESS(HttpStatus.CREATED, "HIKING_201_1", "코스 난이도 피드백 저장에 성공했습니다."), /** diff --git a/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java b/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java index 1ea50ad8..517e11ed 100644 --- a/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java +++ b/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java @@ -9,6 +9,7 @@ import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse; +import com.semosan.api.domain.hiking.dto.response.HikingRecordDetailResponse; import com.semosan.api.domain.hiking.service.HikingRecordService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -76,6 +77,17 @@ public ResponseEntity> getUserHi return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_SUMMARY_SUCCESS, response); } + // 등산 기록 단건 상세 조회 — 이동 경로(track) + 마일스톤 사진 포함. + @GetMapping("/{hikingRecordId}") + @Override + public ResponseEntity> getHikingRecordDetail( + @AuthenticationPrincipal Long userId, + @PathVariable Long hikingRecordId + ) { + HikingRecordDetailResponse response = hikingRecordService.getHikingRecordDetail(userId, hikingRecordId); + return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_DETAIL_SUCCESS, response); + } + // 코스 난이도 피드백을 저장합니다. @PostMapping("/{hikingRecordId}/difficulty-feedback") @Override diff --git a/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java b/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java index 8df7425b..3be5289b 100644 --- a/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java @@ -7,6 +7,7 @@ import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse; +import com.semosan.api.domain.hiking.dto.response.HikingRecordDetailResponse; import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -94,6 +95,35 @@ ResponseEntity> getUserHikingRec @AuthenticationPrincipal Long userId ); + @Operation( + summary = "등산 기록 단건 상세 조회", + description = "본인이 참여한 등산 기록 1건의 상세 정보를 조회합니다. " + + "기록 메타(코스/거리/소요시간/고도/칼로리) 와 함께 실제 이동 경로(GeoJSON LineString) · 점별 고도 배열 · 마일스톤 사진 마커를 함께 반환합니다. " + + "트래킹 세션 없이 만들어진 기록(수동 입력) 의 경우 track/altitudes 는 null 로 내려갑니다." + ) + @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> getHikingRecordDetail( + @Parameter(hidden = true) + @AuthenticationPrincipal Long userId, + @Parameter(description = "등산 기록 ID", required = true) + @PathVariable Long hikingRecordId + ); + @Operation( summary = "코스 난이도 피드백 저장", description = "코스 기반 등산 기록 1개에 대해 사용자가 체감한 난이도 비교 피드백을 저장합니다." diff --git a/src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java b/src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java new file mode 100644 index 00000000..bb87090f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java @@ -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 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 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() + ); + } +} diff --git a/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java b/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java index eac479e7..5473f241 100644 --- a/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java +++ b/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java @@ -1,6 +1,7 @@ package com.semosan.api.domain.hiking.entity; import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.hiking.service.CalorieCalculator; import com.semosan.api.domain.mountain.entity.Course; import com.semosan.api.domain.mountain.entity.Mountain; import com.semosan.api.domain.tracking.entity.TrackingSession; @@ -94,13 +95,17 @@ public static HikingRecord fromTrackingSession( Double descent ) { int duration = computeDurationSeconds(session); + // 사용자 체중 + 코스 강도(ascent/distance) 기반 MET 공식. + // 체중 미등록 시 CalorieCalculator 내부 기본값(65kg) 으로 대체된다. + Double weight = session.getUser() == null ? null : session.getUser().getWeight(); + int calories = CalorieCalculator.calculate(weight, distance, ascent, duration); return HikingRecord.builder() .mountain(session.getMountain()) .course(session.getCourse()) .trackingSession(session) .duration(duration) .maxAltitude(maxAltitude == null ? 0.0 : maxAltitude) - .calories(0) // TODO: 칼로리 산정 공식 도입 시 교체 + .calories(calories) .distance(distance) .ascent(ascent) .descent(descent) diff --git a/src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java b/src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java new file mode 100644 index 00000000..d13ba9ae --- /dev/null +++ b/src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java @@ -0,0 +1,55 @@ +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 { + + 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; + } +} diff --git a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java index c8a997ea..464c34e7 100644 --- a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java +++ b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java @@ -7,6 +7,7 @@ import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse; +import com.semosan.api.domain.hiking.dto.response.HikingRecordDetailResponse; import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback; import com.semosan.api.domain.hiking.entity.HikingRecord; import com.semosan.api.domain.hiking.repository.CourseDifficultyFeedbackRepository; @@ -14,6 +15,10 @@ import com.semosan.api.domain.hiking.repository.HikingRecordRepository; import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordSummaryProjection; import com.semosan.api.domain.mountain.repository.MountainRepository; +import com.semosan.api.domain.tracking.entity.TrackingPhoto; +import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository; +import com.semosan.api.domain.tracking.repository.TrackingPointRepository; +import com.semosan.api.domain.tracking.repository.projection.TrackingPathProjection; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; @@ -24,6 +29,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Optional; + @Service @RequiredArgsConstructor public class HikingRecordService { @@ -36,6 +44,8 @@ public class HikingRecordService { private final MountainRepository mountainRepository; private final HikingMemberRepository hikingMemberRepository; private final CourseDifficultyFeedbackRepository courseDifficultyFeedbackRepository; + private final TrackingPointRepository trackingPointRepository; + private final TrackingPhotoRepository trackingPhotoRepository; // 유저가 다녀온 산 목록을 산 단위로 묶어 조회합니다. @Transactional(readOnly = true) @@ -68,6 +78,33 @@ public Page getUserHikingRecordsByMountainId( .map(GetUserHikingRecordResponse::from); } + // 등산 기록 단건 상세를 조회합니다. (이동 경로 + 마일스톤 사진 포함) + @Transactional(readOnly = true) + public HikingRecordDetailResponse getHikingRecordDetail(Long userId, Long hikingRecordId) { + User user = userReader.findCompletedOnboardingUserById(userId); + HikingRecord record = hikingRecordRepository.findById(hikingRecordId) + .orElseThrow(() -> new GeneralException(ErrorStatus.HIKING_RECORD_NOT_FOUND)); + + if (!hikingMemberRepository.existsByHikingRecordAndUser(record, user)) { + throw new GeneralException(ErrorStatus.HIKING_RECORD_FORBIDDEN); + } + + String track = null; + String altitudes = null; + List photos = List.of(); + if (record.getTrackingSession() != null) { + Long sessionId = record.getTrackingSession().getId(); + Optional path = trackingPointRepository.findTrackBySessionId(sessionId); + if (path.isPresent() && path.get().getTrack() != null) { + track = path.get().getTrack(); + altitudes = path.get().getAltitudes(); + } + photos = trackingPhotoRepository.findByTrackingSession_IdOrderByMilestoneIndexAsc(sessionId); + } + + return HikingRecordDetailResponse.of(record, track, altitudes, photos); + } + // 유저의 등산 기록 요약 정보를 조회합니다. @Transactional(readOnly = true) public GetUserHikingRecordSummaryResponse getUserHikingRecordSummary(Long userId) { diff --git a/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPointRepository.java b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPointRepository.java index 59b1af1a..4873b3be 100644 --- a/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPointRepository.java +++ b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPointRepository.java @@ -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 { List 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 findTrackBySessionId(@Param("sessionId") Long sessionId); } diff --git a/src/main/java/com/semosan/api/domain/tracking/repository/projection/TrackingPathProjection.java b/src/main/java/com/semosan/api/domain/tracking/repository/projection/TrackingPathProjection.java new file mode 100644 index 00000000..5498ac11 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/repository/projection/TrackingPathProjection.java @@ -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(); +} From d5805af4c1658407a1bd8d73aeddae7da9c86d28 Mon Sep 17 00:00:00 2001 From: JangInho Date: Fri, 29 May 2026 23:34:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EB=93=B1=EC=82=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20fetch=20j?= =?UTF-8?q?oin=20+=20=EC=B9=BC=EB=A1=9C=EB=A6=AC=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=B2=B4=EC=A4=91=20TODO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HikingRecord 단건 상세 조회 시 Mountain/Course 를 fetch join 으로 함께 가져와 응답 조립 단계의 LAZY 추가 SELECT 2건을 제거. - 칼로리 기본 체중(65kg) 대체 정책에 향후 개선 방향(성별·나이 추정, 온보딩 단계 입력 강제) TODO 주석 추가. --- .../hiking/repository/HikingRecordRepository.java | 13 +++++++++++++ .../domain/hiking/service/CalorieCalculator.java | 2 ++ .../domain/hiking/service/HikingRecordService.java | 3 ++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java b/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java index a38bfeda..2289d3e0 100644 --- a/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java +++ b/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java @@ -11,9 +11,22 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface HikingRecordRepository extends JpaRepository { + /** + * 단건 상세 조회용. Mountain 은 INNER, Course 는 LEFT JOIN FETCH(자유기록은 null) 로 한 쿼리에 묶는다. + * 응답 조립 시 mountain.name / course.name 접근으로 발생하는 추가 SELECT 를 막는다. + */ + @Query(""" + SELECT hr FROM HikingRecord hr + JOIN FETCH hr.mountain + LEFT JOIN FETCH hr.course + WHERE hr.id = :id + """) + Optional findWithMountainAndCourseById(@Param("id") Long id); + @Query( value = """ SELECT hm.hiking_record_id diff --git a/src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java b/src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java index d13ba9ae..7af061fb 100644 --- a/src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java +++ b/src/main/java/com/semosan/api/domain/hiking/service/CalorieCalculator.java @@ -12,6 +12,8 @@ */ public final class CalorieCalculator { + // TODO: 체중 미등록 시 한국 성인 평균치(65kg) 로 일괄 대체 중. 향후 사용자 성별·나이 기반 추정값 + // 또는 온보딩 단계에서 체중 입력 강제로 정확도 개선 검토. static final double DEFAULT_WEIGHT_KG = 65.0; private static final double MET_EASY = 6.0; diff --git a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java index 464c34e7..ac948419 100644 --- a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java +++ b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java @@ -82,7 +82,8 @@ public Page getUserHikingRecordsByMountainId( @Transactional(readOnly = true) public HikingRecordDetailResponse getHikingRecordDetail(Long userId, Long hikingRecordId) { User user = userReader.findCompletedOnboardingUserById(userId); - HikingRecord record = hikingRecordRepository.findById(hikingRecordId) + // Mountain / Course 를 fetch join 으로 함께 가져와 응답 조립 시 추가 SELECT 를 막는다. + HikingRecord record = hikingRecordRepository.findWithMountainAndCourseById(hikingRecordId) .orElseThrow(() -> new GeneralException(ErrorStatus.HIKING_RECORD_NOT_FOUND)); if (!hikingMemberRepository.existsByHikingRecordAndUser(record, user)) {