diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 5cd113c..d1eb808 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -2,28 +2,27 @@ import static com.server.running_handai.global.response.ResponseCode.*; -import com.server.running_handai.domain.course.dto.CourseCreateRequestDto; -import com.server.running_handai.domain.course.dto.CourseDetailDto; -import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; -import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; -import com.server.running_handai.domain.course.dto.CourseSummaryDto; -import com.server.running_handai.domain.course.dto.GpxCourseRequestDto; +import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.course.service.CourseService; import com.server.running_handai.global.oauth.CustomOAuth2User; import com.server.running_handai.global.response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; + +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -97,6 +96,44 @@ public ResponseEntity> getCourseSummary( return ResponseEntity.ok(CommonResponse.success(SUCCESS, courseSummary)); } + @Operation(summary = "GPX 파일 다운로드", description = "GPX 파일을 다운로드할 수 있는 Presigned GET URL을 발급합니다. 해당 URL의 유효시간은 1시간입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS"), + @ApiResponse(responseCode = "403", description = "실패 (해당 사용자가 만든 코스가 아님) - NOT_COURSE_CREATOR"), + @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스) - COURSE_NOT_FOUND") + }) + @GetMapping("/api/members/me/courses/{courseId}/gpx") + public ResponseEntity> downloadGpx( + @Parameter(description = "다운로드하려는 코스 ID", required = true) + @PathVariable("courseId") Long courseId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + log.info("[코스 GPX 다운로드] courseId: {}, memberId: {}", courseId, memberId); + GpxPathDto gpxPath = courseService.downloadGpx(courseId, memberId); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, gpxPath)); + } + + @Operation(summary = "내 코스 전체 조회", description = "사용자가 생성한 코스 목록을 정렬 조건에 따라 조회합니다. 정렬 조건은 최신순, 오래된순, 짧은 거리순, 긴 거리순으로 총 4개입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS") + }) + @GetMapping("/api/members/me/courses") + public ResponseEntity> getMyCourses( + @Parameter( + description = "정렬 조건", + schema = @Schema(allowableValues = {"latest", "oldest", "short", "long"}) + ) + @RequestParam(defaultValue = "latest") String sortBy, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + MyCourseDetailDto myCourseDetail = courseService.getMyCourses(memberId, sortBy); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, myCourseDetail)); + } + @Operation(summary = "지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공") @@ -145,5 +182,4 @@ public ResponseEntity> deleteMemberCourse( courseService.deleteMemberCourse(memberId, courseId); return ResponseEntity.ok(CommonResponse.success(SUCCESS_COURSE_REMOVE, null)); } - } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/GpxPathDto.java b/src/main/java/com/server/running_handai/domain/course/dto/GpxPathDto.java new file mode 100644 index 0000000..59747b5 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/GpxPathDto.java @@ -0,0 +1,13 @@ +package com.server.running_handai.domain.course.dto; + +public record GpxPathDto( + Long courseId, + String gpxPath +) { + public static GpxPathDto from(Long courseId, String gpxPath) { + return new GpxPathDto( + courseId, + gpxPath + ); + } +} diff --git a/src/main/java/com/server/running_handai/domain/course/dto/MyCourseDetailDto.java b/src/main/java/com/server/running_handai/domain/course/dto/MyCourseDetailDto.java new file mode 100644 index 0000000..26ca171 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/MyCourseDetailDto.java @@ -0,0 +1,15 @@ +package com.server.running_handai.domain.course.dto; + +import java.util.List; + +public record MyCourseDetailDto( + int courseCount, + List courses +) { + public static MyCourseDetailDto from(List courseInfoDtos) { + return new MyCourseDetailDto( + courseInfoDtos.size(), + courseInfoDtos + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Course.java b/src/main/java/com/server/running_handai/domain/course/entity/Course.java index 0e882ad..32e32a4 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Course.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Course.java @@ -4,21 +4,7 @@ import com.server.running_handai.domain.spot.entity.CourseSpot; import com.server.running_handai.domain.review.entity.Review; import com.server.running_handai.global.entity.BaseTimeEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; diff --git a/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java index f8fdac2..d62793f 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java +++ b/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java @@ -4,6 +4,8 @@ import com.server.running_handai.domain.course.entity.Course; import java.util.List; import java.util.Optional; + +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -92,5 +94,26 @@ public interface CourseRepository extends JpaRepository { "WHERE c.id = :courseId") Optional findCourseWithDetailsById(@Param("courseId") Long courseId); + /** + * Member가 생성한 Course 목록을 정렬 조건에 따라 조회 + */ + @Query( + value = "SELECT " + + " c.course_id AS id, " + + " c.name, " + + " ci.img_url AS thumbnailUrl, " + + " c.distance, " + + " c.duration, " + + " c.max_ele AS maxElevation, " + + " 0.0 AS distanceFromUser " + + "FROM " + + " course c " + + "LEFT JOIN " + + " course_image ci ON c.course_id = ci.course_id " + + "WHERE c.member_id = :memberId ", + nativeQuery = true + ) + List findMyCoursesBySort(@Param("memberId") Long memberId, Sort sort); + boolean existsByName(String name); } diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java index 4d1e212..ee353e5 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java @@ -9,16 +9,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; -import com.server.running_handai.domain.course.dto.CourseCreateRequestDto; -import com.server.running_handai.domain.course.dto.CourseSummaryDto; -import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; -import com.server.running_handai.domain.bookmark.dto.BookmarkInfoDto; -import com.server.running_handai.domain.course.dto.CourseDetailDto; -import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; -import com.server.running_handai.domain.course.dto.CourseInfoDto; -import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; -import com.server.running_handai.domain.course.dto.GpxCourseRequestDto; -import com.server.running_handai.domain.course.dto.TrackPointDto; +import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.TrackPoint; import com.server.running_handai.domain.course.event.CourseCreatedEvent; @@ -31,7 +22,9 @@ import com.server.running_handai.domain.review.service.ReviewService; import com.server.running_handai.domain.spot.dto.SpotInfoDto; import com.server.running_handai.domain.spot.repository.SpotRepository; +import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; + import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -45,6 +38,7 @@ import org.locationtech.jts.geom.LineString; import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Sort; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -65,6 +59,7 @@ public class CourseService { private final MemberRepository memberRepository; private final GeometryFactory geometryFactory; private final ReviewService reviewService; + private final FileService fileService; private final CourseDataService courseDataService; private final FileService fileService; private final KakaoMapService kakaoMapService; @@ -236,6 +231,47 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { return CourseSummaryDto.from(course, reviewCount, starAverage, reviewInfoDtos, spotInfoDtos); } + /** + * 내가 생성한 코스의 GPX 파일 다운로드를 위한 Presigned GET URL을 발급합니다. + * 해당 URL의 유효시간은 1시간입니다. + * + * @param courseId 다운로드하려는 코스 ID + * @param memberId 다운로드 요청한 회원 ID + * @return GPX 파일 다운로드용 Presigned GET URL이 포함된 DTO + */ + public GpxPathDto downloadGpx(Long courseId, Long memberId) { + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); + + // 해당 Course를 만든 Member가 아닌 경우 + if (!course.getCreator().getId().equals(memberId)) { + throw new BusinessException(ResponseCode.NOT_COURSE_CREATOR); + } + + // Presigned GET URL 발급 (1시간) + String gpxPath = fileService.getPresignedGetUrl(course.getGpxPath(), 60); + + return GpxPathDto.from(courseId, gpxPath); + } + + /** + * 사용자가 생성한 코스 목록을 정렬 조건에 따라 조회합니다. + * + * @param memberId 조회 요청한 회원 ID + * @param sortBy 정렬 조건 (latest, oldest, short, long) + * @return 정렬된 코스 목록이 포함된 DTO + */ + public MyCourseDetailDto getMyCourses(Long memberId, String sortBy) { + Sort sort = switch (sortBy) { + case "oldest" -> Sort.by("created_at").ascending(); + case "short" -> Sort.by("distance").ascending(); + case "long" -> Sort.by("distance").descending(); + default -> Sort.by("created_at").descending(); + }; + + List courseInfoDtos = courseRepository.findMyCoursesBySort(memberId, sort); + return MyCourseDetailDto.from(courseInfoDtos); + } + /** * 주어진 좌표가 부산 내에 있는지 판별합니다. * diff --git a/src/main/java/com/server/running_handai/global/response/ResponseCode.java b/src/main/java/com/server/running_handai/global/response/ResponseCode.java index 4ee76cd..8040150 100644 --- a/src/main/java/com/server/running_handai/global/response/ResponseCode.java +++ b/src/main/java/com/server/running_handai/global/response/ResponseCode.java @@ -45,6 +45,7 @@ public enum ResponseCode { // FORBIDDEN (403) ACCESS_DENIED(FORBIDDEN, "접근 권한이 없습니다."), + NOT_COURSE_CREATOR(FORBIDDEN, "해당 코스를 만든 사용자가 아닙니다."), NO_AUTHORITY_TO_DELETE_COURSE(FORBIDDEN, "코스 삭제 권한이 없습니다."), // NOT_FOUND (404) diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java index da5afbe..db4375b 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java @@ -3,6 +3,7 @@ import static com.server.running_handai.domain.course.entity.CourseFilter.*; import static com.server.running_handai.domain.course.service.CourseService.MYSQL_POINT_FORMAT; import static com.server.running_handai.global.response.ResponseCode.COURSE_NOT_FOUND; +import static com.server.running_handai.global.response.ResponseCode.NOT_COURSE_CREATOR; import static com.server.running_handai.global.response.ResponseCode.DUPLICATE_COURSE_NAME; import static com.server.running_handai.global.response.ResponseCode.MEMBER_NOT_FOUND; import static com.server.running_handai.global.response.ResponseCode.NO_AUTHORITY_TO_DELETE_COURSE; @@ -14,29 +15,14 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; -import com.server.running_handai.domain.course.dto.CourseCreateRequestDto; -import com.server.running_handai.domain.course.dto.CourseDetailDto; -import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; -import com.server.running_handai.domain.course.dto.CourseInfoDto; -import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; -import com.server.running_handai.domain.course.dto.CourseSummaryDto; -import com.server.running_handai.domain.course.dto.GpxCourseRequestDto; -import com.server.running_handai.domain.course.entity.Area; -import com.server.running_handai.domain.course.entity.Course; -import com.server.running_handai.domain.course.entity.CourseFilter; -import com.server.running_handai.domain.course.entity.CourseImage; -import com.server.running_handai.domain.course.entity.CourseLevel; -import com.server.running_handai.domain.course.entity.RoadCondition; -import com.server.running_handai.domain.course.entity.Theme; -import com.server.running_handai.domain.course.entity.TrackPoint; +import com.server.running_handai.domain.course.dto.*; +import com.server.running_handai.domain.course.entity.*; import com.server.running_handai.domain.course.event.CourseCreatedEvent; import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.course.repository.TrackPointRepository; @@ -54,9 +40,7 @@ import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -66,6 +50,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; @@ -74,6 +59,7 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Sort; import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; @@ -565,6 +551,85 @@ void getCourseSummary_fail_courseNotFound() { } @Nested + @DisplayName("GPX 다운로드 테스트" +) + class CourseGpxDownloadTest { + // 헬퍼 메서드 + private Member createMockMember(Long memberId) { + Member member = Member.builder().build(); + ReflectionTestUtils.setField(member, "id", memberId); + return member; + } + + private Course createMockCourse(Long courseId, Member member) { + Course course = Course.builder().gpxPath("https://s3-bucket.com/course-1.gpx").build(); + ReflectionTestUtils.setField(course, "id", courseId); + ReflectionTestUtils.setField(course, "creator", member); + return course; + } + + /** + * [GPX 다운로드] 성공 + */ + @Test + @DisplayName("GPX 파일 다운로드 성공") + void gpxDownload_success() { + // given + Member member = createMockMember(MEMBER_ID); + Course course = createMockCourse(COURSE_ID, member); + String presignedUrl = "https://presigned-url.com/course-1.gpx"; + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(fileService.getPresignedGetUrl(course.getGpxPath(), 60)).willReturn(presignedUrl); + + // when + GpxPathDto result = courseService.downloadGpx(course.getId(), member.getId()); + + // then + assertThat(result.courseId()).isEqualTo(course.getId()); + assertThat(result.gpxPath()).isEqualTo(presignedUrl); + + verify(courseRepository).findById(COURSE_ID); + verify(fileService).getPresignedGetUrl(course.getGpxPath(), 60); + } + + /** + * [GPX 다운로드] 실패 + * 1. 요청한 Course가 없는 경우 + */ + @Test + @DisplayName("GPX 파일 다운로드 실패 - Course가 없음") + void gpxDownload_fail_courseNotFound() { + // given + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.empty()); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> courseService.downloadGpx(COURSE_ID, MEMBER_ID)); + assertThat(exception.getResponseCode()).isEqualTo(COURSE_NOT_FOUND); + } + + /** + * [GPX 다운로드] 실패 + * 2. 요청한 사용자가 만든 Course가 아닐 경우 + */ + @Test + @DisplayName("GPX 파일 다운로드 실패 - 요청한 사용자가 만든 Course가 아님") + void gpxDownload_fail_notCourseCreator() { + // given + Long otherMemberId = 999L; + Member otherMember = createMockMember(otherMemberId); + Course course = createMockCourse(COURSE_ID, otherMember); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> courseService.downloadGpx(COURSE_ID, MEMBER_ID)); + assertThat(exception.getResponseCode()).isEqualTo(NOT_COURSE_CREATOR); + + verify(courseRepository).findById(COURSE_ID); + verify(fileService, never()).getPresignedGetUrl(course.getGpxPath(), 60); + } + @DisplayName("지역 판별 테스트") class RegionCheckTest { private final ObjectMapper objectMapper = new ObjectMapper(); @@ -726,6 +791,71 @@ void createMemberCourse_fail_memberNotFound() { } @Nested + @DisplayName("내 코스 전체 조회 테스트") + class GetMyCoursesTest { + // 헬퍼 메서드 + private CourseInfoDto createCourseInfoDto() { + CourseInfoDto courseInfoDto = Mockito.mock(CourseInfoDto.class); + return courseInfoDto; + } + + /** + * [내 코스 전체 조회] 성공 + * 1. Course가 존재하는 경우 + */ + @ParameterizedTest + @ValueSource(strings = {"latest", "oldest", "short", "long"}) + @DisplayName("내 코스 전체 조회 성공 - Course가 존재") + void getMyCourses_success_courseExists(String sortBy) { + // given + Sort sort = switch (sortBy) { + case "oldest" -> Sort.by("created_at").ascending(); + case "short" -> Sort.by("distance").ascending(); + case "long" -> Sort.by("distance").descending(); + default -> Sort.by("created_at").descending(); + }; + + List courseInfoDtos = List.of( + createCourseInfoDto(), + createCourseInfoDto(), + createCourseInfoDto() + ); + + given(courseRepository.findMyCoursesBySort(MEMBER_ID, sort)).willReturn(courseInfoDtos); + + // when + MyCourseDetailDto result = courseService.getMyCourses(MEMBER_ID, sortBy); + + // then + assertThat(result.courseCount()).isEqualTo(3); + assertThat(result.courses()).hasSize(3); + + verify(courseRepository).findMyCoursesBySort(MEMBER_ID, sort); + } + + /** + * [내 코스 전체 조회] 성공 + * 2. Course가 존재하지 않는 경우 + */ + @Test + @DisplayName("내 코스 전체 조회 성공 - Course가 존재하지 않음") + void getMyCourses_success_noCourse() { + // given + // Course가 존재하지 않으면 빈 리스트로 응답해야 함 (정렬 조건은 기본값으로 설정) + String sortBy = "latest"; + Sort sort = Sort.by("created_at").descending(); + given(courseRepository.findMyCoursesBySort(MEMBER_ID, sort)).willReturn(Collections.emptyList()); + + // when + MyCourseDetailDto result = courseService.getMyCourses(MEMBER_ID, sortBy); + + // then + assertThat(result.courseCount()).isEqualTo(0); + assertThat(result.courses()).isEmpty(); + + verify(courseRepository).findMyCoursesBySort(MEMBER_ID, sort); + } + @DisplayName("내 코스 삭제 테스트") class MyCourseDeleteTest {