diff --git a/src/main/java/com/semosan/api/domain/hiking/dto/response/GetUserHikingMountainRecordResponse.java b/src/main/java/com/semosan/api/domain/hiking/dto/response/GetUserHikingMountainRecordResponse.java index 0db8c57..ef0f84d 100644 --- a/src/main/java/com/semosan/api/domain/hiking/dto/response/GetUserHikingMountainRecordResponse.java +++ b/src/main/java/com/semosan/api/domain/hiking/dto/response/GetUserHikingMountainRecordResponse.java @@ -3,11 +3,13 @@ import com.semosan.api.domain.hiking.repository.projection.UserHikingMountainRecordProjection; import java.time.LocalDate; +import java.util.List; +import java.util.stream.Stream; public record GetUserHikingMountainRecordResponse( Long mountainId, String mountainName, - String imageUrl, + List imageUrls, Long hikingCount, LocalDate lastHikedAt ) { @@ -17,9 +19,15 @@ public static GetUserHikingMountainRecordResponse from(UserHikingMountainRecordP return new GetUserHikingMountainRecordResponse( projection.getMountainId(), projection.getMountainName(), - projection.getImageUrl(), + buildImageUrls(projection.getImageUrl1(), projection.getImageUrl2()), projection.getHikingCount(), projection.getLastHikedAt().toLocalDate() ); } + + private static List buildImageUrls(String imageUrl1, String imageUrl2) { + return Stream.of(imageUrl1, imageUrl2) + .filter(imageUrl -> imageUrl != null && !imageUrl.isBlank()) + .toList(); + } } 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 489049b..66201ea 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 @@ -46,17 +46,59 @@ AND NOT EXISTS ( @Query( value = """ SELECT - hr.mountain_id AS mountainId, - m.name AS mountainName, - m.image_urls->>0 AS imageUrl, - COUNT(hr.id) AS hikingCount, - MAX(hr.created_at) AS lastHikedAt - FROM hiking_records hr - JOIN hiking_members hm ON hm.hiking_record_id = hr.id - JOIN mountains m ON m.id = hr.mountain_id - WHERE hm.user_id = :userId - GROUP BY hr.mountain_id, m.name, m.image_urls->>0 - ORDER BY MAX(hr.created_at) DESC, hr.mountain_id DESC + grouped.mountain_id AS mountainId, + grouped.mountain_name AS mountainName, + photos.image_url1 AS imageUrl1, + photos.image_url2 AS imageUrl2, + grouped.hiking_count AS hikingCount, + grouped.last_hiked_at AS lastHikedAt + FROM ( + SELECT + hr.mountain_id, + m.name AS mountain_name, + m.location AS mountain_location, + COUNT(hr.id) AS hiking_count, + MAX(hr.created_at) AS last_hiked_at + FROM hiking_records hr + JOIN hiking_members hm ON hm.hiking_record_id = hr.id + JOIN mountains m ON m.id = hr.mountain_id + WHERE hm.user_id = :userId + GROUP BY hr.mountain_id, m.name, m.location + ) grouped + LEFT JOIN LATERAL ( + SELECT + MAX(CASE WHEN ranked.rn = 1 THEN ranked.image_url END) AS image_url1, + MAX(CASE WHEN ranked.rn = 2 THEN ranked.image_url END) AS image_url2 + FROM ( + SELECT + tp.image_url, + ROW_NUMBER() OVER ( + ORDER BY + ST_Distance( + grouped.mountain_location, + ST_SetSRID(ST_MakePoint(tp.lng, tp.lat), 4326)::geography + ) ASC, + tp.captured_at DESC, + tp.id DESC + ) AS rn + FROM tracking_photos tp + JOIN tracking_sessions ts ON ts.id = tp.tracking_session_id + WHERE ts.user_id = :userId + AND ts.mountain_id = grouped.mountain_id + AND ts.status = 'COMPLETED' + AND grouped.mountain_location IS NOT NULL + AND NULLIF(tp.image_url, '') IS NOT NULL + ORDER BY + ST_Distance( + grouped.mountain_location, + ST_SetSRID(ST_MakePoint(tp.lng, tp.lat), 4326)::geography + ) ASC, + tp.captured_at DESC, + tp.id DESC + LIMIT 2 + ) ranked + ) photos ON true + ORDER BY grouped.last_hiked_at DESC, grouped.mountain_id DESC """, countQuery = """ SELECT COUNT(DISTINCT hr.mountain_id) diff --git a/src/main/java/com/semosan/api/domain/hiking/repository/projection/UserHikingMountainRecordProjection.java b/src/main/java/com/semosan/api/domain/hiking/repository/projection/UserHikingMountainRecordProjection.java index b973467..dcd3126 100644 --- a/src/main/java/com/semosan/api/domain/hiking/repository/projection/UserHikingMountainRecordProjection.java +++ b/src/main/java/com/semosan/api/domain/hiking/repository/projection/UserHikingMountainRecordProjection.java @@ -8,7 +8,9 @@ public interface UserHikingMountainRecordProjection { String getMountainName(); - String getImageUrl(); + String getImageUrl1(); + + String getImageUrl2(); Long getHikingCount(); diff --git a/src/test/java/com/semosan/api/domain/hiking/dto/response/GetUserHikingMountainRecordResponseTest.java b/src/test/java/com/semosan/api/domain/hiking/dto/response/GetUserHikingMountainRecordResponseTest.java new file mode 100644 index 0000000..c247e73 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/hiking/dto/response/GetUserHikingMountainRecordResponseTest.java @@ -0,0 +1,74 @@ +package com.semosan.api.domain.hiking.dto.response; + +import com.semosan.api.domain.hiking.repository.projection.UserHikingMountainRecordProjection; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class GetUserHikingMountainRecordResponseTest { + + @Test + void fromMapsImageUrlsInOrder() { + GetUserHikingMountainRecordResponse response = GetUserHikingMountainRecordResponse.from( + projection("near-summit-1", "near-summit-2") + ); + + assertThat(response.imageUrls()).containsExactly("near-summit-1", "near-summit-2"); + assertThat(response.lastHikedAt()).isEqualTo(LocalDate.of(2026, 5, 28)); + } + + @Test + void fromFiltersBlankImageUrls() { + GetUserHikingMountainRecordResponse response = GetUserHikingMountainRecordResponse.from( + projection("", "near-summit-2") + ); + + assertThat(response.imageUrls()).containsExactly("near-summit-2"); + } + + @Test + void fromReturnsEmptyImageUrlsWhenNoPhotos() { + GetUserHikingMountainRecordResponse response = GetUserHikingMountainRecordResponse.from( + projection(null, null) + ); + + assertThat(response.imageUrls()).isEmpty(); + } + + private UserHikingMountainRecordProjection projection(String imageUrl1, String imageUrl2) { + return new UserHikingMountainRecordProjection() { + @Override + public Long getMountainId() { + return 1L; + } + + @Override + public String getMountainName() { + return "관악산"; + } + + @Override + public String getImageUrl1() { + return imageUrl1; + } + + @Override + public String getImageUrl2() { + return imageUrl2; + } + + @Override + public Long getHikingCount() { + return 3L; + } + + @Override + public LocalDateTime getLastHikedAt() { + return LocalDateTime.of(2026, 5, 28, 10, 0); + } + }; + } +}