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 @@ -84,6 +84,7 @@ public enum ErrorStatus implements BaseStatus {
MOUNTAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_1", "산을 찾을 수 없습니다."),
MOUNTAIN_LIKE_ALREADY_EXISTS(HttpStatus.CONFLICT, "MTN_409_1", "이미 좋아요한 산입니다."),
MOUNTAIN_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_2", "좋아요한 산이 아닙니다."),
MOUNTAIN_BBOX_PARTIAL(HttpStatus.BAD_REQUEST, "MTN_400_1", "BBox 좌표는 4개(swLat, swLng, neLat, neLng) 모두 보내거나 모두 비워주세요."),

/**
* Image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@ public enum SuccessStatus implements BaseStatus {
MOUNTAIN_LIKE_SUCCESS(HttpStatus.OK, "MTN_200_4", "산 좋아요 등록에 성공했습니다."),
MOUNTAIN_UNLIKE_SUCCESS(HttpStatus.OK, "MTN_200_5", "산 좋아요 취소에 성공했습니다."),
LIKED_MOUNTAIN_LIST_SUCCESS(HttpStatus.OK, "MTN_200_6", "좋아요한 산 목록 조회에 성공했습니다."),
MOUNTAIN_MAP_SUCCESS(HttpStatus.OK, "MTN_200_7", "지도 영역 내 산 조회에 성공했습니다."),
MOUNTAIN_RECOMMENDATION_SUCCESS(HttpStatus.OK, "MTN_200_8", "레벨 맞춤 산 추천 조회에 성공했습니다."),

/**
* Hiking Record
*/
GET_HIKING_RECORD_MOUNTAIN_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_1", "내가 다녀온 산 목록 조회에 성공했습니다."),
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", "특정 산의 나의 등산 기록 목록 조회에 성공했습니다."),

/**
* Image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand Down Expand Up @@ -46,6 +47,20 @@ public ResponseEntity<ApiResponse<PageResponse<GetUserHikingMountainRecordRespon
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_MOUNTAIN_LIST_SUCCESS, response);
}

// 특정 산에 대한 나의 등산 기록 목록(코스 단위)을 조회합니다.
@GetMapping("/me/mountains/{mountainId}")
@Override
public ResponseEntity<ApiResponse<PageResponse<GetUserHikingRecordResponse>>> getUserHikingRecordsByMountainId(
@AuthenticationPrincipal Long userId,
@PathVariable Long mountainId,
@PageableDefault(size = 10) Pageable pageable
) {
PageResponse<GetUserHikingRecordResponse> response = PageResponse.from(
hikingRecordService.getUserHikingRecordsByMountainId(userId, mountainId, pageable)
);
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_LIST_BY_MOUNTAIN_SUCCESS, response);
}

// 나의 등산 기록 요약 정보를 조회합니다.
@GetMapping("/me/summary")
@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;

@Tag(name = "Hiking Record", description = "등산 기록 관련 API")
public interface HikingRecordControllerDocs {
Expand Down Expand Up @@ -49,6 +52,29 @@ ResponseEntity<ApiResponse<PageResponse<GetUserHikingMountainRecordResponse>>> g
@PageableDefault(size = 10) Pageable pageable
);

@Operation(
summary = "특정 산의 나의 등산 기록 목록 조회",
description = "지정된 산에 대해 사용자가 다녀온 등산 기록을 기록(코스) 단위로 조회합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "특정 산의 나의 등산 기록 목록 조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "산을 찾을 수 없음",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<PageResponse<GetUserHikingRecordResponse>>> getUserHikingRecordsByMountainId(
@Parameter(hidden = true)
@AuthenticationPrincipal Long userId,
@Parameter(description = "산 ID", required = true)
@PathVariable Long mountainId,
@PageableDefault(size = 10) Pageable pageable
);

@Operation(
summary = "나의 등산 기록 요약 조회",
description = "마이페이지에서 사용하는 누적 등산 횟수, 정복한 산 수, 누적 등산 고도를 조회합니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,45 @@
import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordProjection;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public record GetUserHikingRecordResponse(
Long hikingRecordId,
Long mountainId,
String mountainName,
Long courseId,
String courseName,
String imageUrl,
List<String> imageUrls,
Double distance,
Integer duration,
LocalDate hikedAt
) {

// 조회 projection을 나의 등산 기록 상세 응답 DTO로 변환합니다.
// imageUrls 순서: photoReport(앞)이 cliveImage(뒤)보다 먼저. null인 필드는 제외.
public static GetUserHikingRecordResponse from(UserHikingRecordProjection projection) {
return new GetUserHikingRecordResponse(
projection.getHikingRecordId(),
projection.getMountainId(),
projection.getMountainName(),
projection.getCourseId(),
projection.getCourseName(),
projection.getImageUrl(),
buildImageUrls(projection.getPhotoReportImageUrl(), projection.getCliveImageUrl()),
projection.getDistance(),
projection.getDuration(),
projection.getHikedAt().toLocalDate()
);
}

private static List<String> buildImageUrls(String photoReportImageUrl, String cliveImageUrl) {
List<String> urls = new ArrayList<>(2);
if (photoReportImageUrl != null) {
urls.add(photoReportImageUrl);
}
if (cliveImageUrl != null) {
urls.add(cliveImageUrl);
}
return urls;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public interface HikingMemberRepository extends JpaRepository<HikingMember, Long
void deleteByUser_Id(@Param("userId") Long userId);

boolean existsByHikingRecordAndUser(HikingRecord hikingRecord, User user);

boolean existsByUser_Id(Long userId);

List<HikingMember> findByHikingRecord(HikingRecord hikingRecord);
Page<HikingMember> findByUser(User user, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ Page<UserHikingMountainRecordProjection> findUserHikingMountainRecordsByUserId(
m.name AS mountainName,
c.id AS courseId,
c.name AS courseName,
COALESCE(hr.photo_report_image_url, hr.clive_image_url, m.image_url) AS imageUrl,
hr.photo_report_image_url AS photoReportImageUrl,
hr.clive_image_url AS cliveImageUrl,
c.distance AS distance,
hr.duration AS duration,
hr.created_at AS hikedAt
Expand Down Expand Up @@ -103,4 +104,38 @@ Page<UserHikingRecordProjection> findUserHikingRecordsByUserId(
nativeQuery = true
)
UserHikingRecordSummaryProjection findUserHikingRecordSummaryByUserId(@Param("userId") Long userId);

@Query(
value = """
SELECT
hr.id AS hikingRecordId,
m.id AS mountainId,
m.name AS mountainName,
c.id AS courseId,
c.name AS courseName,
hr.photo_report_image_url AS photoReportImageUrl,
hr.clive_image_url AS cliveImageUrl,
c.distance AS distance,
hr.duration AS duration,
hr.created_at AS hikedAt
FROM hiking_records hr
JOIN hiking_members hm ON hm.hiking_record_id = hr.id
JOIN mountains m ON m.id = hr.mountain_id
JOIN courses c ON c.id = hr.course_id
WHERE hm.user_id = :userId AND hr.mountain_id = :mountainId
ORDER BY hr.created_at DESC, hr.id DESC
""",
countQuery = """
SELECT COUNT(DISTINCT hr.id)
FROM hiking_records hr
JOIN hiking_members hm ON hm.hiking_record_id = hr.id
WHERE hm.user_id = :userId AND hr.mountain_id = :mountainId
""",
nativeQuery = true
)
Page<UserHikingRecordProjection> findUserHikingRecordsByUserIdAndMountainId(
@Param("userId") Long userId,
@Param("mountainId") Long mountainId,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public interface UserHikingRecordProjection {

String getCourseName();

String getImageUrl();
String getPhotoReportImageUrl();

String getCliveImageUrl();

Double getDistance();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.semosan.api.domain.hiking.service;

import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
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.repository.HikingRecordRepository;
import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordSummaryProjection;
import com.semosan.api.domain.mountain.repository.MountainRepository;
import com.semosan.api.domain.user.service.UserReader;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand All @@ -18,6 +21,7 @@ public class HikingRecordService {

private final HikingRecordRepository hikingRecordRepository;
private final UserReader userReader;
private final MountainRepository mountainRepository;

// 유저가 다녀온 산 목록을 산 단위로 묶어 조회합니다.
@Transactional(readOnly = true)
Expand All @@ -35,6 +39,21 @@ public Page<GetUserHikingRecordResponse> getUserHikingRecords(Long userId, Pagea
.map(GetUserHikingRecordResponse::from);
}

// 특정 산에 대한 유저의 등산 기록 목록을 기록 단위로 조회합니다.
@Transactional(readOnly = true)
public Page<GetUserHikingRecordResponse> getUserHikingRecordsByMountainId(
Long userId,
Long mountainId,
Pageable pageable
Comment thread
JangInho marked this conversation as resolved.
) {
userReader.findCompletedOnboardingUserById(userId);
if (!mountainRepository.existsById(mountainId)) {
throw new GeneralException(ErrorStatus.MOUNTAIN_NOT_FOUND);
}
return hikingRecordRepository.findUserHikingRecordsByUserIdAndMountainId(userId, mountainId, pageable)
.map(GetUserHikingRecordResponse::from);
}

// 유저의 등산 기록 요약 정보를 조회합니다.
@Transactional(readOnly = true)
public GetUserHikingRecordSummaryResponse getUserHikingRecordSummary(Long userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.semosan.api.domain.mountain.dto.response.LikedMountainResponse;
import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse;
import com.semosan.api.domain.mountain.dto.response.MountainListResponse;
import com.semosan.api.domain.mountain.dto.response.MountainMapListResponse;
import com.semosan.api.domain.mountain.dto.response.MountainRecommendationResponse;
import com.semosan.api.domain.mountain.service.MountainLikeService;
import com.semosan.api.domain.mountain.service.MountainService;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -45,6 +47,46 @@ public ResponseEntity<ApiResponse<PageResponse<MountainListResponse>>> searchMou
return ApiResponse.success(SuccessStatus.MOUNTAIN_SEARCH_SUCCESS, response);
}

/**
* 지도에 표시할 산 목록을 BBox(Bounding Box, 사각형 영역) 기준으로 조회한다.
* BBox는 사각형의 두 꼭짓점 좌표로 정의된다.
* - sw* (South-West): 남서쪽 꼭짓점 (사각형의 좌측 하단)
* - ne* (North-East): 북동쪽 꼭짓점 (사각형의 우측 상단)
* 네 값이 모두 null이면 서비스 레이어에서 서울 기본 BBox가 적용된다.
* 일부만 채워진 경우(1~3개)는 의도가 모호하므로 400 BAD_REQUEST 로 거부한다.
*/
@GetMapping("/map")
@Override
public ResponseEntity<ApiResponse<MountainMapListResponse>> getMountainsForMap(
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) Double swLat,
@RequestParam(required = false) Double swLng,
@RequestParam(required = false) Double neLat,
@RequestParam(required = false) Double neLng
) {
MountainMapListResponse response = mountainService.getMountainsForMap(userId, swLat, swLng, neLat, neLng);
return ApiResponse.success(SuccessStatus.MOUNTAIN_MAP_SUCCESS, response);
}

/**
* 사용자의 현재 위치(lat, lng)에서 가까운 순으로 레벨 맞춤 산을 추천한다.
* lat, lng 는 필수임. 누락 시 400 BAD_REQUEST.
* Pageable.sort 는 무시됨 (서버 정책상 "거리 가까운 순" 고정).
*/
@GetMapping("/recommendations")
@Override
public ResponseEntity<ApiResponse<PageResponse<MountainRecommendationResponse>>> getRecommendedMountains(
@AuthenticationPrincipal Long userId,
@RequestParam Double lat,
@RequestParam Double lng,
@PageableDefault(size = 10) Pageable pageable
) {
PageResponse<MountainRecommendationResponse> response = PageResponse.from(
mountainService.getRecommendedMountains(userId, lat, lng, pageable)
);
return ApiResponse.success(SuccessStatus.MOUNTAIN_RECOMMENDATION_SUCCESS, response);
}

@GetMapping("/likes")
@Override
public ResponseEntity<ApiResponse<PageResponse<LikedMountainResponse>>> getLikedMountains(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.semosan.api.domain.mountain.dto.response.LikedMountainResponse;
import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse;
import com.semosan.api.domain.mountain.dto.response.MountainListResponse;
import com.semosan.api.domain.mountain.dto.response.MountainMapListResponse;
import com.semosan.api.domain.mountain.dto.response.MountainRecommendationResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand Down Expand Up @@ -53,6 +55,62 @@ ResponseEntity<ApiResponse<PageResponse<MountainListResponse>>> searchMountains(
@PageableDefault(size = 10) Pageable pageable
);

@Operation(
summary = "지도 영역 내 산 조회 (홈 화면)",
description = "지도 화면에 표시할 산 목록을 BBox(남서/북동 좌표) 기준으로 조회합니다. "
+ "로그인한 사용자의 등산 기록을 기반으로 visited, visitCount, imageUrl이 채워집니다. "
+ "BBox 4개 좌표가 모두 비어있으면 서울 기본 영역이 적용됩니다. "
+ "1~3개만 채워진 부분 입력은 의도가 모호하므로 400 으로 거부합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "지도 영역 내 산 조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "BBox 4개 좌표 중 일부만 보낸 경우 (MTN_400_1)"
)
})
ResponseEntity<ApiResponse<MountainMapListResponse>> getMountainsForMap(
@AuthenticationPrincipal Long userId,
@Parameter(description = "BBox 남서쪽 꼭짓점의 위도 (사각형 아래쪽 변, 예: 37.413)")
@RequestParam(required = false) Double swLat,
@Parameter(description = "BBox 남서쪽 꼭짓점의 경도 (사각형 왼쪽 변, 예: 126.764)")
@RequestParam(required = false) Double swLng,
@Parameter(description = "BBox 북동쪽 꼭짓점의 위도 (사각형 위쪽 변, 예: 37.715)")
@RequestParam(required = false) Double neLat,
@Parameter(description = "BBox 북동쪽 꼭짓점의 경도 (사각형 오른쪽 변, 예: 127.184)")
@RequestParam(required = false) Double neLng
);

@Operation(
summary = "레벨 맞춤 산 추천 (홈 화면)",
description = "로그인 사용자의 등산 레벨(HikingLevel) → 산 난이도(Difficulty) 매핑으로 후보를 추리고, "
+ "사용자 위치(lat, lng)에서 가까운 순으로 정렬해 페이지 단위로 반환합니다. "
+ "온보딩이 완료되지 않은 사용자는 모든 난이도가 fallback으로 사용됩니다. "
+ "Pageable.sort 는 무시됩니다 (서버 정책상 '거리 가까운 순' 고정)."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "레벨 맞춤 산 추천 조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "lat 또는 lng 파라미터 누락",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<PageResponse<MountainRecommendationResponse>>> getRecommendedMountains(
@AuthenticationPrincipal Long userId,
@Parameter(description = "사용자 현재 위치 위도 (필수)", required = true, example = "37.4533700")
@RequestParam Double lat,
@Parameter(description = "사용자 현재 위치 경도 (필수)", required = true, example = "126.9571678")
@RequestParam Double lng,
@PageableDefault(size = 10) Pageable pageable
);

@Operation(
summary = "좋아요한 산 목록 조회",
description = "로그인한 사용자가 좋아요한 산 목록을 조회합니다."
Expand Down
Loading