Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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", "코스 난이도 피드백 저장에 성공했습니다."),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +77,17 @@ public ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> getUserHi
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_SUMMARY_SUCCESS, response);
}

// 등산 기록 단건 상세 조회 — 이동 경로(track) + 마일스톤 사진 포함.
@GetMapping("/{hikingRecordId}")
@Override
public ResponseEntity<ApiResponse<HikingRecordDetailResponse>> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,6 +95,35 @@ ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> 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<ApiResponse<HikingRecordDetailResponse>> getHikingRecordDetail(
@Parameter(hidden = true)
@AuthenticationPrincipal Long userId,
@Parameter(description = "등산 기록 ID", required = true)
@PathVariable Long hikingRecordId
);

@Operation(
summary = "코스 난이도 피드백 저장",
description = "코스 기반 등산 기록 1개에 대해 사용자가 체감한 난이도 비교 피드백을 저장합니다."
Expand Down
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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,22 @@
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface HikingRecordRepository extends JpaRepository<HikingRecord, Long> {

/**
* 단건 상세 조회용. 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<HikingRecord> findWithMountainAndCourseById(@Param("id") Long id);

@Query(
value = """
SELECT hm.hiking_record_id
Expand Down
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
Comment on lines +29 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[P2] 새롭게 추가된 칼로리 계산 로직(CalorieCalculator)과 등산 기록 상세 조회 API에 대한 단위 테스트가 누락되어 있습니다.\n\n### 이유\n칼로리 계산 공식(CalorieCalculator.calculate)은 체중 유무, 등산 강도(MET) 분기 등 다양한 경계 조건(Edge Case)을 포함하고 있으므로, 비즈니스 로직의 정확성을 보장하고 향후 변경 시 오작동을 방지하기 위해 철저한 단위 테스트가 필요합니다.\n\n### 해결 방안\nCalorieCalculatorTest를 추가하여 다음과 같은 시나리오들을 검증하는 테스트 코드를 작성하는 것을 권장합니다:\n- 체중이 없는 경우 기본 체중(65kg)으로 계산되는지 여부\n- 소요 시간이 0 이하일 때 0을 반환하는지 여부\n- 누적 상승고도와 거리 비율에 따라 MET 강도(EASY, MEDIUM, HARD)가 올바르게 판별되는지 여부

) {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
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;
import com.semosan.api.domain.hiking.repository.HikingMemberRepository;
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;
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -68,6 +78,34 @@ public Page<GetUserHikingRecordResponse> getUserHikingRecordsByMountainId(
.map(GetUserHikingRecordResponse::from);
}

// 등산 기록 단건 상세를 조회합니다. (이동 경로 + 마일스톤 사진 포함)
@Transactional(readOnly = true)
public HikingRecordDetailResponse getHikingRecordDetail(Long userId, Long hikingRecordId) {
User user = userReader.findCompletedOnboardingUserById(userId);
// Mountain / Course 를 fetch join 으로 함께 가져와 응답 조립 시 추가 SELECT 를 막는다.
HikingRecord record = hikingRecordRepository.findWithMountainAndCourseById(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<TrackingPhoto> photos = List.of();
if (record.getTrackingSession() != null) {
Long sessionId = record.getTrackingSession().getId();
Optional<TrackingPathProjection> 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) {
Expand Down
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);
}
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();
}