diff --git a/.env.example b/.env.example index 5efe231..422eb9a 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ MYSQL_ROOT_PASSWORD= # Durunubi API key DURUNUBI_SERVICE_KEY= +# Spot API Key +SPOT_SERVICE_KEY= + # OpenAI API Key OPENAI_API_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 8fdeb36..9275108 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} - DURUNUBI_SERVICE_KEY=${DURUNUBI_SERVICE_KEY} + - SPOT_SERVICE_KEY=${SPOT_SERVICE_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY} - S3_ACCESS_KEY=${S3_ACCESS_KEY} - S3_SECRET_KEY=${S3_SECRET_KEY} diff --git a/sql/V1_create_course_related_tables.sql b/sql/V1_create_course_related_tables.sql index 36ab21b..1c3caea 100644 --- a/sql/V1_create_course_related_tables.sql +++ b/sql/V1_create_course_related_tables.sql @@ -115,7 +115,46 @@ create table track_point foreign key (course_id) references course (course_id) ); --- 8. review 테이블 생성 +-- 8. spot 테이블 생성 +create table spot ( + spot_id BIGINT AUTO_INCREMENT PRIMARY KEY, + external_id VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + description TEXT, + category ENUM('NATURE', 'HISTORY', 'RECREATION', 'EXPERIENCE', 'INDUSTRIAL', 'ARCHITECTURE', + 'KOREAN_FOOD', 'WESTERN_FOOD', 'JAPANESE_FOOD', 'CHINESE_FOOD', 'GLOBAL_FOOD', + 'CAFE', 'CLUB', 'UNKNOWN') NOT NULL, + lat DOUBLE NOT NULL, + lon DOUBLE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL +); + +-- 9. course_spot 테이블 생성 +CREATE TABLE course_spot ( + course_spot_id BIGINT AUTO_INCREMENT PRIMARY KEY, + course_id BIGINT NOT NULL, + spot_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT course_spot_uk UNIQUE (course_id, spot_id), + CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES course(course_id), + CONSTRAINT fk_spot FOREIGN KEY (spot_id) REFERENCES spot(spot_id) +); + +-- 10. spot_image 테이블 생성 +CREATE TABLE spot_image ( + spot_img_id BIGINT AUTO_INCREMENT PRIMARY KEY, + img_url VARCHAR(255) NOT NULL, + original_url VARCHAR(255) NOT NULL, + spot_id BIGINT NOT NULL UNIQUE, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT fk_spot_image_spot FOREIGN KEY (spot_id) REFERENCES spot(spot_id) +); + +-- 11. review 테이블 생성 create table review ( review_id bigint auto_increment @@ -138,4 +177,4 @@ create table review ALTER TABLE course DROP COLUMN tour_point; -- course 테이블의 distance 컬럼 타입 변경 -ALTER TABLE course MODIFY COLUMN distance DOUBLE NOT NULL; \ No newline at end of file +ALTER TABLE course MODIFY COLUMN distance DOUBLE NOT NULL; diff --git a/src/main/java/com/server/running_handai/RunningHandaiApplication.java b/src/main/java/com/server/running_handai/RunningHandaiApplication.java index a91d48b..39d05cf 100644 --- a/src/main/java/com/server/running_handai/RunningHandaiApplication.java +++ b/src/main/java/com/server/running_handai/RunningHandaiApplication.java @@ -5,9 +5,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableAsync @EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class RunningHandaiApplication { diff --git a/src/main/java/com/server/running_handai/domain/bookmark/controller/BookmarkController.java b/src/main/java/com/server/running_handai/domain/bookmark/controller/BookmarkController.java index a1ac4df..ab027f3 100644 --- a/src/main/java/com/server/running_handai/domain/bookmark/controller/BookmarkController.java +++ b/src/main/java/com/server/running_handai/domain/bookmark/controller/BookmarkController.java @@ -1,6 +1,8 @@ package com.server.running_handai.domain.bookmark.controller; +import com.server.running_handai.domain.bookmark.dto.BookmarkedCourseInfoDto; import com.server.running_handai.domain.bookmark.service.BookmarkService; +import com.server.running_handai.domain.course.entity.Area; import com.server.running_handai.global.oauth.CustomOAuth2User; import com.server.running_handai.global.response.CommonResponse; import com.server.running_handai.global.response.ResponseCode; @@ -9,20 +11,21 @@ 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 java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Slf4j @RestController @RequiredArgsConstructor -@RequestMapping("/api/courses/{courseId}/bookmarks") @Tag(name = "Bookmark", description = "북마크 관련 API") public class BookmarkController { @@ -35,7 +38,7 @@ public class BookmarkController { @ApiResponse(responseCode = "400", description = "실패 (이미 북마크한 코스)"), @ApiResponse(responseCode = "401", description = "실패 (인증 실패)") }) - @PostMapping + @PostMapping("/api/courses/{courseId}/bookmarks") public ResponseEntity> registerBookmark( @Parameter(description = "북마크 대상 코스", required = true) @PathVariable Long courseId, @AuthenticationPrincipal CustomOAuth2User customOAuth2User @@ -54,7 +57,7 @@ public ResponseEntity> registerBookmark( @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 북마크)"), @ApiResponse(responseCode = "401", description = "실패 (인증 실패)") }) - @DeleteMapping + @DeleteMapping("/api/courses/{courseId}/bookmarks") public ResponseEntity> deleteBookmark( @Parameter(description = "북마크 대상 코스", required = true) @PathVariable Long courseId, @AuthenticationPrincipal CustomOAuth2User customOAuth2User @@ -66,4 +69,25 @@ public ResponseEntity> deleteBookmark( return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS_BOOKMARK_DELETE, null)); } + @Operation(summary = "북마크한 코스 조회", description = "회원이 북마크한 코스를 조회합니다. 코스의 지역으로 조건 검색이 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "200", description = "성공 (북마크한 코스 없음)"), + @ApiResponse(responseCode = "400", description = "실패 (요청 파라미터 오류)"), + @ApiResponse(responseCode = "401", description = "실패 (인증 실패)") + }) + @GetMapping("/api/members/me/courses/bookmarks") + public ResponseEntity> getBookmarkedCourses( + @Parameter(description = "지역 조건 (전체 보기인 경우 null)") + @RequestParam(required = false) Area area, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + log.info("[북마크한 코스 조회] memberId={}, area={}", memberId, (area != null) ? area.name() : null); + List responseData = bookmarkService.getBookmarkedCoursesByMemberAndArea(memberId, area); + if (responseData.isEmpty()) { + return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS_EMPTY_BOOKMARKS, responseData)); + } + return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, responseData)); + } } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/BookmarkCountDto.java b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkCountDto.java similarity index 56% rename from src/main/java/com/server/running_handai/domain/course/dto/BookmarkCountDto.java rename to src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkCountDto.java index 0e48a78..38db1bd 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/BookmarkCountDto.java +++ b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkCountDto.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.dto; +package com.server.running_handai.domain.bookmark.dto; public record BookmarkCountDto(Long courseId, Long bookmarkCount) { } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/BookmarkInfoDto.java b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkInfoDto.java similarity index 57% rename from src/main/java/com/server/running_handai/domain/course/dto/BookmarkInfoDto.java rename to src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkInfoDto.java index 24800cf..e5acd47 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/BookmarkInfoDto.java +++ b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkInfoDto.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.dto; +package com.server.running_handai.domain.bookmark.dto; public record BookmarkInfoDto(int totalCount, boolean isBookmarked) { } diff --git a/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkedCourseInfoDto.java b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkedCourseInfoDto.java new file mode 100644 index 0000000..8913fdb --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkedCourseInfoDto.java @@ -0,0 +1,39 @@ +package com.server.running_handai.domain.bookmark.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +@JsonPropertyOrder({ + "bookmarkId", + "courseId", + "thumbnailUrl", + "distance", + "duration", + "maxElevation", + "isBookmarked", + "bookmarkCount" +}) +public interface BookmarkedCourseInfoDto { + long getBookmarkId(); + long getCourseId(); + String getThumbnailUrl(); + + @JsonIgnore + double getRawDistance(); + + default int getDistance() { + return (int) getRawDistance(); + } + + int getDuration(); + + @JsonIgnore + double getRawMaxElevation(); // JPA 전용 + + default int getMaxElevation() { // 클라이언트 전용 + return (int) getRawMaxElevation(); + } + + boolean getIsBookmarked(); + int getBookmarkCount(); +} diff --git a/src/main/java/com/server/running_handai/domain/bookmark/dto/MyBookmarkInfoDto.java b/src/main/java/com/server/running_handai/domain/bookmark/dto/MyBookmarkInfoDto.java new file mode 100644 index 0000000..b5f766f --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/bookmark/dto/MyBookmarkInfoDto.java @@ -0,0 +1,11 @@ +package com.server.running_handai.domain.bookmark.dto; + +public record MyBookmarkInfoDto( + String courseThumbnailUrl, + int bookmarkCount, + boolean isBookmarked +) { + public static MyBookmarkInfoDto from(String courseThumbnailUrl, int bookmarkCount, boolean isBookmarked) { + return new MyBookmarkInfoDto(courseThumbnailUrl, bookmarkCount, isBookmarked); + } +} diff --git a/src/main/java/com/server/running_handai/domain/bookmark/repository/BookmarkRepository.java b/src/main/java/com/server/running_handai/domain/bookmark/repository/BookmarkRepository.java index 25a999d..63f80de 100644 --- a/src/main/java/com/server/running_handai/domain/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/com/server/running_handai/domain/bookmark/repository/BookmarkRepository.java @@ -1,7 +1,9 @@ package com.server.running_handai.domain.bookmark.repository; +import com.server.running_handai.domain.bookmark.dto.BookmarkedCourseInfoDto; import com.server.running_handai.domain.bookmark.entity.Bookmark; -import com.server.running_handai.domain.course.dto.BookmarkCountDto; +import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; +import com.server.running_handai.domain.course.entity.Area; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.member.entity.Member; import java.util.List; @@ -23,15 +25,51 @@ public interface BookmarkRepository extends JpaRepository { int countByCourseId(Long courseId); // 코스 ID별 북마크 수 조회 - @Query("SELECT new com.server.running_handai.domain.course.dto.BookmarkCountDto(b.course.id, COUNT(b)) " + @Query("SELECT new com.server.running_handai.domain.bookmark.dto.BookmarkCountDto(b.course.id, COUNT(b)) " + "FROM Bookmark b " + "WHERE b.course.id " + "IN :courseIds " + "GROUP BY b.course.id") List countByCourseIdIn(@Param("courseIds") List courseIds); - // 사용자가 북마크한 코스 ID 목록 조회 + // 코스 목록 중에서 사용자가 북마크한 코스 조회 @Query("SELECT b.course.id FROM Bookmark b WHERE b.course.id IN :courseIds AND b.member.id = :memberId") Set findBookmarkedCourseIdsByMember(@Param("courseIds") List courseIds, @Param("memberId") Long memberId); + // 사용자가 북마크한 모든 코스 조회 + @Query("SELECT " + + "b.id AS bookmarkId, " + + "c.id AS courseId, " + + "ci.imgUrl AS thumbnailUrl, " + + "c.distance AS rawDistance, " + + "c.duration AS duration, " + + "c.maxElevation AS rawMaxElevation, " + + "true AS isBookmarked, " + + "(SELECT count(b2.id) FROM Bookmark b2 WHERE b2.course.id = c.id) AS bookmarkCount " // 총 북마크 수 계산 + + "FROM Bookmark b " + + "LEFT JOIN b.course c " + + "LEFT JOIN c.courseImage ci " + + "WHERE b.member.id = :memberId " + + "ORDER BY b.createdAt DESC " + ) + List findBookmarkedCoursesByMemberId(Long memberId); + + // 사용자가 북마크한 코스 중에서 특정 지역의 코스만 조회 + @Query("SELECT " + + "b.id AS bookmarkId, " + + "c.id AS courseId, " + + "ci.imgUrl AS thumbnailUrl, " + + "c.distance AS rawDistance, " + + "c.duration AS duration, " + + "c.maxElevation AS rawMaxElevation, " + + "true AS isBookmarked, " + + "(SELECT count(b2.id) FROM Bookmark b2 WHERE b2.course.id = c.id) AS bookmarkCount " // 총 북마크 수 계산 + + "FROM Bookmark b " + + "LEFT JOIN b.course c " + + "LEFT JOIN c.courseImage ci " + + "WHERE b.member.id = :memberId " + + "AND c.area = :area " + + "ORDER BY b.createdAt DESC " + ) + List findBookmarkedCoursesByMemberIdAndArea(Long memberId, Area area); } diff --git a/src/main/java/com/server/running_handai/domain/bookmark/service/BookmarkService.java b/src/main/java/com/server/running_handai/domain/bookmark/service/BookmarkService.java index 12b8891..e1fe5c7 100644 --- a/src/main/java/com/server/running_handai/domain/bookmark/service/BookmarkService.java +++ b/src/main/java/com/server/running_handai/domain/bookmark/service/BookmarkService.java @@ -1,19 +1,24 @@ package com.server.running_handai.domain.bookmark.service; +import com.server.running_handai.domain.bookmark.dto.BookmarkedCourseInfoDto; import com.server.running_handai.domain.bookmark.entity.Bookmark; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; +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.repository.CourseRepository; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import com.server.running_handai.domain.member.entity.Member; import com.server.running_handai.domain.member.repository.MemberRepository; +import java.util.Collections; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class BookmarkService { private final BookmarkRepository bookmarkRepository; @@ -53,4 +58,25 @@ public void deleteBookmark(Long memberId, Long courseId) { // 북마크 삭제 bookmarkRepository.delete(bookmark); } + + /** + * 회원이 북마크한 코스를 조회합니다. + * + * @param memberId 요청한 회원의 ID + * @return 북마크한 코스 정보 DTO + */ + public List getBookmarkedCoursesByMemberAndArea(Long memberId, Area area) { + List bookmarkedCourseInfoDtos; + if (area == null) { // 지역 전체인 경우 + bookmarkedCourseInfoDtos = bookmarkRepository.findBookmarkedCoursesByMemberId(memberId); + } else { // 특정 지역 필터링한 경우 + bookmarkedCourseInfoDtos = bookmarkRepository.findBookmarkedCoursesByMemberIdAndArea(memberId, area); + } + + if (bookmarkedCourseInfoDtos.isEmpty()) { + return Collections.emptyList(); + } + + return bookmarkedCourseInfoDtos; + } } diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java index 7b216f2..42e0742 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java @@ -33,14 +33,14 @@ public ResponseEntity> updateRoadConditions(@PathVariable Long @PutMapping(value = "/{courseId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateCourseImage(@PathVariable Long courseId, - @RequestParam MultipartFile courseImageFile) throws IOException { + @RequestParam MultipartFile courseImageFile) { courseDataService.updateCourseImage(courseId, courseImageFile); return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); } @PostMapping(value = "/gpx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> createCourseToGpx(@RequestPart("courseInfo") GpxCourseRequestDto gpxCourseRequestDto, - @RequestParam("courseGpxFile") MultipartFile courseGpxFile) throws IOException { + @RequestParam("courseGpxFile") MultipartFile courseGpxFile) { courseDataService.createCourseToGpx(gpxCourseRequestDto, courseGpxFile); return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CourseDetailDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CourseDetailDto.java index f010705..b5f7af8 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/CourseDetailDto.java +++ b/src/main/java/com/server/running_handai/domain/course/dto/CourseDetailDto.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.course.dto; +import com.server.running_handai.domain.bookmark.dto.BookmarkInfoDto; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.RoadCondition; import java.util.List; @@ -7,7 +8,7 @@ public record CourseDetailDto( Long courseId, String courseName, - double distance, + int distance, int duration, int minElevation, int maxElevation, @@ -24,7 +25,7 @@ public static CourseDetailDto from(Course course, List trackPoint return new CourseDetailDto( course.getId(), course.getName(), - course.getDistance(), + (int) course.getDistance(), course.getDuration(), (int) course.getMinElevation().doubleValue(), (int) course.getMaxElevation().doubleValue(), diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CourseInfoWithDetailsDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CourseInfoWithDetailsDto.java index 6eff7ad..eab4173 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/CourseInfoWithDetailsDto.java +++ b/src/main/java/com/server/running_handai/domain/course/dto/CourseInfoWithDetailsDto.java @@ -6,7 +6,7 @@ public record CourseInfoWithDetailsDto( long courseId, String courseName, String thumbnailUrl, - double distance, + int distance, int duration, int maxElevation, double distanceFromUser, @@ -19,7 +19,7 @@ public static CourseInfoWithDetailsDto from(CourseInfoDto courseInfoDto, List reviews, + List spots ) { - public static CourseSummaryDto from(Course course, ReviewInfoListDto reviewInfoListDto) { + public static CourseSummaryDto from(Course course, int reviewCount, double starAverage, + List reviewInfoDtos, List spotInfoDtos) { + return new CourseSummaryDto( - course.getDistance(), + (int) course.getDistance(), course.getDuration(), - course.getMaxElevation(), - reviewInfoListDto + (int) course.getMaxElevation().doubleValue(), + reviewCount, + starAverage, + reviewInfoDtos, + spotInfoDtos ); } } 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 ff18eec..9cd9224 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 @@ -1,5 +1,6 @@ package com.server.running_handai.domain.course.entity; +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; @@ -89,6 +90,10 @@ public class Course extends BaseTimeEntity { @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) private List reviews = new ArrayList<>(); + // CourseSpot과 일대다 관계 + @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) + private List courseSpots = new ArrayList<>(); + @Builder public Course(String externalId, String name, double distance, int duration, CourseLevel level, Area area, String gpxPath, diff --git a/src/main/java/com/server/running_handai/domain/course/scheduler/CourseScheduler.java b/src/main/java/com/server/running_handai/domain/course/scheduler/CourseScheduler.java new file mode 100644 index 0000000..319ed1b --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/scheduler/CourseScheduler.java @@ -0,0 +1,31 @@ +package com.server.running_handai.domain.course.scheduler; + +import com.server.running_handai.domain.course.service.CourseDataService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CourseScheduler { + + private final CourseDataService courseDataService; + + /** + * 매일 새벽 4시에 코스 데이터 동기화 작업을 실행합니다. + * cron = "[초] [분] [시] [일] [월] [요일]" + */ + @Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul") // 매일 새벽 4시 30분 0초 + public void scheduleDurunubiCourseSync() { + log.info("[스케줄러] 두루누비 코스 동기화 작업을 시작합니다."); + try { + courseDataService.synchronizeCourseData(); + log.info("[스케줄러] 두루누비 코스 동기화 작업을 성공적으로 완료했습니다."); + } catch (Exception e) { + log.error("[스케줄러] 두루누비 코스 동기화 작업 중 오류가 발생했습니다.", e); + } + } + +} diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java index 81b0f00..893614b 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java @@ -19,10 +19,10 @@ import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.course.repository.RoadConditionRepository; import com.server.running_handai.domain.course.repository.TrackPointRepository; -import com.server.running_handai.domain.course.util.TrackPointSimplificationUtil; +import com.server.running_handai.global.util.TrackPointSimplificationUtil; +import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; -import java.io.IOException; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -93,8 +93,10 @@ public void synchronizeCourseData() { Map dbCourseMap = courseRepository.findByExternalIdIsNotNull().stream() .collect(Collectors.toMap(Course::getExternalId, course -> course)); + List newCourses = new ArrayList<>(); // 새롭게 추가된 코스 + List updatedCourses = new ArrayList<>(); // 수정된 기존 코스 + // API 데이터를 기준으로 루프를 돌며 DB 데이터와 비교 - List toSave = new ArrayList<>(); for (Map.Entry entry : apiCourseMap.entrySet()) { String externalId = entry.getKey(); Item courseItem = entry.getValue(); @@ -123,21 +125,37 @@ public void synchronizeCourseData() { log.info("[두루누비 코스 동기화] 트랙포인트 업데이트 완료: courseId={}, count={}", dbCourse.getId(), trackPoints.size()); if (dbCourse.syncWith(apiCourse)) { - toSave.add(dbCourse); - log.info("[두루누비 코스 동기화] 코스 데이터 변경 감지 (UPDATE): courseId={}, externalId={}", dbCourse.getId(), externalId); + updatedCourses.add(dbCourse); + log.info("[두루누비 코스 동기화] 기존 코스 변경 (UPDATE): courseId={}, externalId={}", dbCourse.getId(), externalId); } dbCourseMap.remove(externalId); // 업데이트 끝난 DB 데이터는 맵에서 제거 (남은 데이터는 DELETE 대상) } else { // DB에 없음 -> 신규 추가 trackPoints.forEach(trackPoint -> trackPoint.setCourse(apiCourse)); - toSave.add(apiCourse); + newCourses.add(apiCourse); log.info("[두루누비 코스 동기화] 신규 코스 저장 (INSERT): externalId={}", externalId); } } - // 추가 또는 수정된 Course 저장 - if (!toSave.isEmpty()) { - courseRepository.saveAll(toSave); - log.info("[두루누비 코스 동기화] {}건의 코스 데이터가 추가/수정되었습니다.", toSave.size()); + // 신규 코스 저장 및 길 상태 업데이트 + if (!newCourses.isEmpty()) { + courseRepository.saveAll(newCourses); + log.info("[두루누비 코스 동기화] {}건의 신규 코스가 추가되었습니다.", newCourses.size()); + + log.info("[두루누비 코스 동기화] {}건의 신규 코스에 대한 길 상태 정보 업데이트를 시작합니다.", newCourses.size()); + for (Course newCourse : newCourses) { + try { + log.info("[두루누비 코스 동기화] 길 상태 업데이트 호출: courseId={}", newCourse.getId()); + updateRoadConditions(newCourse.getId()); + } catch (Exception e) { + log.error("[두루누비 코스 동기화] 길 상태 업데이트 실패: courseId={}. 동기화를 계속합니다.", newCourse.getId(), e); + } + } + } + + // 수정된 코스 저장 + if (!updatedCourses.isEmpty()) { + courseRepository.saveAll(updatedCourses); + log.info("[두루누비 코스 동기화] {}건의 코스 데이터가 수정되었습니다.", updatedCourses.size()); } // DB에만 있고 두루누비에서 없어진 Course 삭제 @@ -410,7 +428,8 @@ public void updateRoadConditions(Long courseId) { log.info("[길 상태 수정] 기존 길 상태 데이터 삭제 완료: courseId={}", courseId); List newRoadConditions = descriptions.stream() - .map(description -> new RoadCondition(course, description)) + .map(description -> + RoadCondition.builder().course(course).description(description).build()) .toList(); roadConditionRepository.saveAll(newRoadConditions); @@ -425,7 +444,7 @@ public void updateRoadConditions(Long courseId) { * @param courseImageFile 업로드된 이미지 파일 */ @Transactional - public void updateCourseImage(Long courseId, MultipartFile courseImageFile) throws IOException { + public void updateCourseImage(Long courseId, MultipartFile courseImageFile) { Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); // 새 파일을 S3에 먼저 업로드 @@ -439,7 +458,7 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) thro if (oldImageUrl != null) { course.getCourseImage().updateImageUrl(newImageUrl); } else { - course.updateCourseImage(new CourseImage(newImageUrl)); + course.updateCourseImage(CourseImage.builder().imgUrl(newImageUrl).build()); } log.info("[코스 이미지 수정] DB에 이미지 정보 갱신 완료: Course ID={}", courseId); @@ -459,7 +478,7 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) thro * @param courseGpxFile 업로드된 GPX 파일 */ @Transactional - public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, MultipartFile courseGpxFile) throws IOException { + public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, MultipartFile courseGpxFile) { log.info("[GPX 코스 생성] 시작: 파일명={}, 크기={} bytes", courseGpxFile.getOriginalFilename(), courseGpxFile.getSize()); // 1. 코스 이름 조합 @@ -473,7 +492,7 @@ public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, Multipart log.info("[GPX 코스 생성] 트랙포인트 파싱 완료 ({}개)", trackPoints.size()); } catch (Exception e) { log.error("[GPX 코스 생성] 트랙포인트 파싱 실패", e); - throw new BusinessException(GPX_FILE_PARSE_FAILED); + throw new BusinessException(ResponseCode.GPX_FILE_PARSE_FAILED); } // 3. 전체 거리 계산 @@ -557,7 +576,7 @@ public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, Multipart List roadConditions = descriptions.stream() .skip(1) .limit(5) - .map(description -> new RoadCondition(course, description)) + .map(description -> RoadCondition.builder().course(course).description(description).build()) .toList(); roadConditionRepository.saveAll(roadConditions); @@ -625,6 +644,11 @@ private List getTrackPoints(MultipartFile courseGpxFile) throws Exce .build()) .collect(Collectors.toList()); } + + if (trackPoints.isEmpty()) { + throw new BusinessException(TRACK_POINTS_NOT_FOUND); + } + return trackPoints; } 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 4edb043..c923fa5 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 @@ -6,8 +6,8 @@ import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; import com.server.running_handai.domain.course.dto.CourseSummaryDto; -import com.server.running_handai.domain.course.dto.BookmarkCountDto; -import com.server.running_handai.domain.course.dto.BookmarkInfoDto; +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; @@ -18,9 +18,10 @@ import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.course.repository.TrackPointRepository; import com.server.running_handai.domain.review.dto.ReviewInfoDto; -import com.server.running_handai.domain.review.dto.ReviewInfoListDto; import com.server.running_handai.domain.review.repository.ReviewRepository; 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.exception.BusinessException; import java.util.Arrays; import java.util.Collections; @@ -50,9 +51,9 @@ public class CourseService { private final TrackPointRepository trackPointRepository; private final BookmarkRepository bookmarkRepository; private final GeometryFactory geometryFactory; + private final SpotRepository spotRepository; private final ReviewRepository reviewRepository; private final ReviewService reviewService; - private final CourseDataService courseDataService; @Value("${course.simplification.distance-tolerance}") private double distanceTolerance; @@ -210,12 +211,13 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { // 리뷰 조회 List reviewInfoDtos = reviewService.convertToReviewInfoDtos( - reviewRepository.findRandom2ByCourseId(courseId), memberId); - double averageStars = reviewService.calculateAverageStars(courseId); - ReviewInfoListDto reviewInfoListDto = ReviewInfoListDto.from(averageStars, reviewInfoDtos); + reviewRepository.findRecent2ByCourseId(courseId), memberId); + int reviewCount = (int) reviewRepository.countByCourseId(courseId); // 리뷰 전체 개수 + double starAverage = reviewService.calculateAverageStars(courseId); // 리뷰 전체 평점 - // TODO 즐길거리 조회 + // 즐길거리 조회 + List spotInfoDtos = spotRepository.findRandom3ByCourseId(course.getId()); - return CourseSummaryDto.from(course, reviewInfoListDto); + return CourseSummaryDto.from(course, reviewCount, starAverage, reviewInfoDtos, spotInfoDtos); } } diff --git a/src/main/java/com/server/running_handai/domain/course/service/FileService.java b/src/main/java/com/server/running_handai/domain/course/service/FileService.java index 6ef2c69..e5d3ae7 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/FileService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/FileService.java @@ -17,6 +17,8 @@ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.URL; import java.time.Duration; import java.util.UUID; @@ -42,6 +44,8 @@ public FileService(S3Client s3Client) { this.s3Client = s3Client; } + private static final String FILENAME_PATTERN = "[^A-Za-z0-9_-]"; + /** * MultipartFile을 S3 버킷에 업로드하고, 업로드된 파일의 URL을 반환합니다. * 파일에 따라 디렉토리로 구분하여 저장합니다. (예: gpx, image) @@ -50,49 +54,69 @@ public FileService(S3Client s3Client) { * @param directory S3 버킷 내 디렉토리 * @return 업로드된 파일의 S3 URL */ - public String uploadFile(MultipartFile multipartFile, String directory) throws IOException { + public String uploadFile(MultipartFile multipartFile, String directory) { String originalFileName = multipartFile.getOriginalFilename(); - String fileName = directory + "/" + UUID.randomUUID() + "_" + originalFileName; - String contentType = multipartFile.getContentType(); - String lowerName = fileName.toLowerCase(); - if (lowerName.endsWith(".png")) { - contentType = "image/png"; - } else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) { - contentType = "image/jpeg"; - } else if (lowerName.endsWith(".gpx")) { - contentType = "application/gpx+xml"; - } else { - throw new IllegalArgumentException("지원하지 않는 파일 형식입니다."); + if (originalFileName == null || originalFileName.isBlank()) { + log.warn("[S3 파일 업로드] 파일명을 찾을 수 없어 기본값 제공"); + originalFileName = "file"; } - try { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucket) - .key(fileName) - .contentType(contentType) - .build(); + String contentType = guessContentType(originalFileName); + validateFileType(originalFileName); + + String newFileName = changeFileName(originalFileName); + String fileName = directory + "/" + UUID.randomUUID() + "_" + newFileName; - s3Client.putObject( - putObjectRequest, - software.amazon.awssdk.core.sync.RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize()) - ); - - String fileUrl = String.format( - "https://%s.s3.%s.amazonaws.com/%s", - bucket, - region, - fileName - ); - return fileUrl; + try { + return uploadToS3(fileName, contentType, multipartFile.getInputStream(), multipartFile.getSize()); } catch (IOException e) { log.error("[S3 파일 업로드] 업로드 실패: 파일명={}, 대상경로={}", originalFileName, fileName, e); throw new BusinessException(ResponseCode.FILE_UPLOAD_FAILED); } } + /** + * 이미지 URL을 통해 파일을 S3 버킷에 업로드하고, 업로드된 파일의 URL을 반환합니다. + * 파일에 따라 디렉토리로 구분하여 저장합니다. (예: gpx, image) + * + * @param fileUrl 이미지 URL + * @param directory S3 버킷 내 디렉토리 + * @return 업로드된 파일의 S3 URL + */ + public String uploadFileByUrl(String fileUrl, String directory) { + try { + URL url = new URL(fileUrl); + String path = url.getPath(); + String originalFileName = path.substring(path.lastIndexOf('/') + 1); + + if (originalFileName.isBlank()) { + log.warn("[S3 파일 업로드] 파일명을 찾을 수 없어 기본값 제공"); + originalFileName = "file"; + } + + String contentType = guessContentType(originalFileName); + validateFileType(originalFileName); + + String newFileName = changeFileName(originalFileName); + String fileName = directory + "/" + UUID.randomUUID() + "_" + newFileName; + + HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); + httpURLConnection.setRequestMethod("GET"); + httpURLConnection.connect(); + + try (InputStream inputStream = httpURLConnection.getInputStream()) { + return uploadToS3(fileName, contentType, inputStream, httpURLConnection.getContentLengthLong()); + } + } catch (IOException e) { + log.error("[S3 파일 업로드] 업로드 실패: fileUrl={}, error={}", fileUrl, e.getMessage(), e); + throw new BusinessException(ResponseCode.FILE_UPLOAD_FAILED); + } + } + /** * S3 버킷에 저장된 파일의 Presigned GET URL을 발급합니다. + * URL은 지정한 유효 시간 동안만 접근 가능합니다. * * @param fileUrl DB에 저장된 S3 파일 URL * @param minutes Presigned URL 유효 시간 @@ -155,6 +179,7 @@ public void deleteFile(String fileUrl) { * S3 파일 URL에서 key를 추출합니다. * * @param fileUrl DB에 저장된 S3 파일 URL + * @return S3 내부 Key 경로 */ private String extractKeyFromUrl(String fileUrl) { int index = fileUrl.indexOf(".amazonaws.com/"); @@ -166,4 +191,95 @@ private String extractKeyFromUrl(String fileUrl) { return fileUrl.substring(index + ".amazonaws.com/".length()); } + + /** + * 파일 이름에서 Content Type을 추정합니다. + * 확장자가 없거나 인식 불가할 경우 기본값으로 application/octet-stream을 반환합니다. + * + * @param filename 파일 이름 + * @return Content Type + */ + private String guessContentType(String filename) { + int dotIndex = filename.lastIndexOf('.'); + String extension = (dotIndex == -1) ? "" : filename.substring(dotIndex + 1).toLowerCase(); + return switch (extension) { + case "png" -> "image/png"; + case "jpg", "jpeg" -> "image/jpeg"; + case "gpx" -> "application/gpx+xml"; + default -> { + log.warn("[S3 파일 업로드] 감지하지 못한 content-type: extension={}", extension); + yield "application/octet-stream"; + } + }; + } + + /** + * 주어진 Content Type이 지원되는 타입인지 확인합니다. + * + * @param fileName 파일 이름 + */ + private void validateFileType(String fileName) { + String lowerName = fileName.toLowerCase(); + + boolean isSupported = lowerName.endsWith(".png") || lowerName.endsWith(".jpg") || + lowerName.endsWith(".jpeg") || (lowerName.endsWith(".gpx")); + + if (!isSupported) { + throw new BusinessException(ResponseCode.UNSUPPORTED_FILE_TYPE); + } + } + + /** + * 저장 시 UTF-8 인코딩이 필요없는 영문으로 파일명을 바꿉니다. + * 확장자는 보존하고, 파일명이 비어있으면 기본값(file)을 사용합니다. + * + * @param originalFileName 원본 파일명 + * @return 허용된 문자로 이루어진 파일명 + */ + private String changeFileName(String originalFileName) { + // 확장자 분리 + int dotIndex = originalFileName.lastIndexOf('.'); + String name = (dotIndex == -1) ? originalFileName : originalFileName.substring(0, dotIndex); + String extension = (dotIndex == -1) ? "" : originalFileName.substring(dotIndex).toLowerCase(); + + // 영문, 숫자, 하이픈, 언더스코어만 허용 + String newFileName = name.replaceAll(FILENAME_PATTERN, ""); + + // 원본 파일명에 허용된 문자가 없어 빈 파일명일 경우 기본값 사용 + if (newFileName.isBlank()) { + log.warn("[S3 파일 업로드] 원본 파일명에 허용된 문자가 없어 기본값 사용: originalFilName={}", originalFileName); + newFileName = "file"; + } + + return newFileName + extension; + } + + /** + * InputStream을 받아 S3에 업로드하고 업로드된 파일 URL을 반환합니다. + * + * @param fileName S3에 저장할 파일 이름 + * @param contentType 파일의 Content Type + * @param inputStream 업로드할 파일 데이터 스트림 + * @param contentLength 업로드 데이터 크기 (byte) + * @return 업로드된 파일의 S3 URL + */ + private String uploadToS3(String fileName, String contentType, InputStream inputStream, long contentLength) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(contentType) + .build(); + + s3Client.putObject( + putObjectRequest, + software.amazon.awssdk.core.sync.RequestBody.fromInputStream(inputStream, contentLength) + ); + + return String.format( + "https://%s.s3.%s.amazonaws.com/%s", + bucket, + region, + fileName + ); + } } diff --git a/src/main/java/com/server/running_handai/domain/member/controller/MemberController.java b/src/main/java/com/server/running_handai/domain/member/controller/MemberController.java index 467aef2..f32378e 100644 --- a/src/main/java/com/server/running_handai/domain/member/controller/MemberController.java +++ b/src/main/java/com/server/running_handai/domain/member/controller/MemberController.java @@ -1,5 +1,9 @@ package com.server.running_handai.domain.member.controller; +import com.server.running_handai.domain.member.dto.MemberUpdateRequestDto; +import com.server.running_handai.domain.member.dto.MemberUpdateResponseDto; +import com.server.running_handai.global.oauth.CustomOAuth2User; +import com.server.running_handai.domain.member.dto.MemberInfoDto; import com.server.running_handai.global.response.CommonResponse; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.domain.member.dto.TokenRequestDto; @@ -9,10 +13,18 @@ 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 jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +@Slf4j +@Validated @RestController @RequiredArgsConstructor @RequestMapping("/api/members") @@ -21,15 +33,81 @@ public class MemberController { private final MemberService memberService; @Operation(summary = "토큰 재발급", - description = "리프래쉬 토큰을 통해 만료된 액세스 토큰을 재발급합니다. 인증에 사용된 리프래시 토큰 역시 액세스 토큰과 함께 재발급됩니다. 로그인은 /oauth2/authorization/{provider}로 요청해주세요.") + description = "리프래쉬 토큰을 통해 만료된 액세스 토큰을 재발급합니다. 인증에 사용된 리프래시 토큰 역시 액세스 토큰과 함께 재발급됩니다. " + + "로그인은 /oauth2/authorization/{provider}로 요청해주세요.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "성공"), - @ApiResponse(responseCode = "401", description = "실패 (유효하지 않은 토큰)"), - @ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 리프래시 토큰)") + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "401", description = "실패 (유효하지 않은 토큰) - INVALID_REFRESH_TOKEN"), + @ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 리프래시 토큰) - REFRESH_TOKEN_NOT_FOUND") }) @PostMapping("/oauth/token") public ResponseEntity> createToken(@RequestBody TokenRequestDto tokenRequestDto) { TokenResponseDto tokenResponseDto = memberService.createToken(tokenRequestDto); return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, tokenResponseDto)); } + + @Operation(summary = "닉네임 중복 여부 조회", + description = "사용자가 수정하려는 닉네임이 중복이 아닌 경우 true, 중복인 경우 false를 응답합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS"), + @ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 사용자) - MEMBER_NOT_FOUND"), + @ApiResponse(responseCode = "400", description = + "실패 (유효성 검증):
" + + "• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH
" + + "• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT
" + + "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME
" + + "• 공백이나 Null 값으로 호출 - INVALID_INPUT_VALUE" + ), + }) + @GetMapping("/nickname") + public ResponseEntity> checkNicknameDuplicate( + @NotBlank @RequestParam("value") String nickname, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + log.info("[닉네임 중복 여부 조회] memberId: {} nickname: {}", memberId, nickname); + Boolean result = memberService.checkNicknameDuplicate(memberId, nickname); + return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, result)); + } + + @Operation(summary = "내 정보 수정", + description = "내 정보를 수정합니다. 현재는 닉네임 수정만 제공합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS"), + @ApiResponse(responseCode = "404", description = "실패 (찾을 수 없는 사용자) - MEMBER_NOT_FOUND"), + @ApiResponse(responseCode = "400", description = + "실패 (유효성 검증):
" + + "• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH
" + + "• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT
" + + "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME
" + + "• 공백이나 Null 값으로 호출 - INVALID_INPUT_VALUE" + ), + @ApiResponse(responseCode = "409", description = "실패 (중복된 닉네임) - DUPLICATE_NICKNAME"), + }) + @PatchMapping("/me") + public ResponseEntity> updateMemberInfo( + @RequestBody @Valid MemberUpdateRequestDto memberUpdateRequestDto, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + log.info("[내 정보 수정] memberId: {}", memberId); + MemberUpdateResponseDto memberUpdateResponseDto = memberService.updateMemberInfo(memberId, memberUpdateRequestDto); + return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, memberUpdateResponseDto)); + } + + @Operation(summary = "내 정보 조회", description = "회원의 닉네임과 이메일을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "401", description = "실패 (유효하지 않은 토큰)"), + }) + @GetMapping("/me") + public ResponseEntity> getMyInfo( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + MemberInfoDto memberInfo = memberService.getMemberInfo(customOAuth2User.getMember().getId()); + return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, memberInfo)); + } + } diff --git a/src/main/java/com/server/running_handai/domain/member/dto/MemberInfoDto.java b/src/main/java/com/server/running_handai/domain/member/dto/MemberInfoDto.java new file mode 100644 index 0000000..2cfdb3e --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/member/dto/MemberInfoDto.java @@ -0,0 +1,12 @@ +package com.server.running_handai.domain.member.dto; + +import com.server.running_handai.domain.member.entity.Member; + +public record MemberInfoDto( + String nickname, + String email +) { + public static MemberInfoDto from(Member member) { + return new MemberInfoDto(member.getNickname(), member.getEmail()); + } +} diff --git a/src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateRequestDto.java b/src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateRequestDto.java new file mode 100644 index 0000000..a78e9f3 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateRequestDto.java @@ -0,0 +1,9 @@ +package com.server.running_handai.domain.member.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MemberUpdateRequestDto ( + @NotBlank + String nickname +) { +} diff --git a/src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateResponseDto.java b/src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateResponseDto.java new file mode 100644 index 0000000..6c5d6a3 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateResponseDto.java @@ -0,0 +1,10 @@ +package com.server.running_handai.domain.member.dto; + +public record MemberUpdateResponseDto ( + Long memberId, + String nickname +) { + public static MemberUpdateResponseDto from(Long memberId, String nickname) { + return new MemberUpdateResponseDto(memberId, nickname); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/member/entity/Member.java b/src/main/java/com/server/running_handai/domain/member/entity/Member.java index 160cd86..7bf20ce 100644 --- a/src/main/java/com/server/running_handai/domain/member/entity/Member.java +++ b/src/main/java/com/server/running_handai/domain/member/entity/Member.java @@ -60,7 +60,10 @@ public Member(String providerId, String email, String nickname, Provider provide this.role = role; } + // ==== 연관관계 편의 메서드 ==== // public void updateRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public void updateNickname(String nickname) { this.nickname = nickname; } } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/member/repository/MemberRepository.java b/src/main/java/com/server/running_handai/domain/member/repository/MemberRepository.java index 3fd2cfd..9394f0a 100644 --- a/src/main/java/com/server/running_handai/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/server/running_handai/domain/member/repository/MemberRepository.java @@ -4,9 +4,22 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MemberRepository extends JpaRepository { + /** + * Provider Id로 사용자를 조회합니다. + */ Optional findByProviderId(String providerId); - boolean existsByNickname(String nickname); + + /** + * 리프래시 토큰으로 사용자를 조회합니다. + */ Optional findByRefreshToken(String refreshToken); + + /** + * 닉네임 중복 여부를 확인합니다. + */ + boolean existsByNickname(String nickname); } diff --git a/src/main/java/com/server/running_handai/domain/member/service/MemberService.java b/src/main/java/com/server/running_handai/domain/member/service/MemberService.java index 26b4380..e0f119d 100644 --- a/src/main/java/com/server/running_handai/domain/member/service/MemberService.java +++ b/src/main/java/com/server/running_handai/domain/member/service/MemberService.java @@ -1,5 +1,8 @@ package com.server.running_handai.domain.member.service; +import com.server.running_handai.domain.member.dto.MemberInfoDto; +import com.server.running_handai.domain.member.dto.MemberUpdateRequestDto; +import com.server.running_handai.domain.member.dto.MemberUpdateResponseDto; import com.server.running_handai.global.jwt.JwtProvider; import com.server.running_handai.global.oauth.userInfo.OAuth2UserInfo; import com.server.running_handai.global.response.ResponseCode; @@ -10,7 +13,7 @@ import com.server.running_handai.domain.member.entity.Role; import com.server.running_handai.domain.member.repository.MemberRepository; import io.jsonwebtoken.ExpiredJwtException; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -24,7 +27,9 @@ public class MemberService { private final MemberRepository memberRepository; private final JwtProvider jwtProvider; - public static final int NICKNAME_NUMBER = 10; + public static final int NICKNAME_MAX_LENGTH = 10; + public static final int NICKNAME_MIN_LENGTH = 2; + private static final String NICKNAME_PATTERN = "^[ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z0-9]{2,10}$"; /** * OAuth2 사용자 정보를 기반으로 회원을 생성하거나 기존 회원을 조회합니다. @@ -130,7 +135,7 @@ private String generateRandomNickname() { String animal = animals.get(random.nextInt(animals.size())); int usedLength = adjective.length() + animal.length(); - int remainLength = NICKNAME_NUMBER - usedLength; + int remainLength = NICKNAME_MAX_LENGTH - usedLength; if (remainLength > 0) { // 이미 선택된 형용사, 동물의 자리수를 확인하여, 남은 수를 숫자에 사용 (최소 1자리, 최대 remainLength) @@ -153,4 +158,86 @@ private String generateRandomNickname() { return nickname; } + + /** + * 닉네임 중복 여부를 조회합니다. + * 닉네임 유효성 검증도 함께 수행합니다. + * + * @param memberId 사용자 Id + * @param nickname 검증할 닉네임 + * @return 중복이지 않으면 true, 중복이면 false. + */ + public Boolean checkNicknameDuplicate(Long memberId, String nickname) { + Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ResponseCode.MEMBER_NOT_FOUND)); + + // 중복 여부 조회 시 문자 앞, 뒤 공백과 영문 대, 소문자는 무시 (프론트 측에서 처리해서 보내줌) + String newNickname = nickname.trim().toLowerCase(); + String currentNickname = member.getNickname().trim().toLowerCase(); + + return isNicknameValid(newNickname, currentNickname); + } + + /** + * 내 정보를 수정합니다. + * 닉네임 유효성 검증도 함께 수행합니다. + * + * @param memberId 사용자 Id + * @param memberUpdateRequestDto 수정하고 싶은 내 정보 Dto + * @return 수정된 내 정보 Dto (MemberUpdateResponseDto) + */ + @Transactional + public MemberUpdateResponseDto updateMemberInfo(Long memberId, MemberUpdateRequestDto memberUpdateRequestDto) { + Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ResponseCode.MEMBER_NOT_FOUND)); + + // 중복 여부 조회 시 문자 앞, 뒤 공백과 영문 대, 소문자는 무시 (프론트 측에서 처리해서 보내줌) + String newNickname = memberUpdateRequestDto.nickname().trim().toLowerCase(); + String currentNickname = member.getNickname().trim().toLowerCase(); + + if (isNicknameValid(newNickname, currentNickname)) { + member.updateNickname(newNickname); + } else { + throw new BusinessException(ResponseCode.DUPLICATE_NICKNAME); + } + + return MemberUpdateResponseDto.from(member.getId(), member.getNickname()); + } + + /** + * 닉네임 유효성을 검증합니다. + * 테스트를 위해 가시성을 완화했습니다. (private -> package-private) + * + * @param newNickname 검증할 닉네임 + * @param currentNickname 사용자의 현재 닉네임 + * @return 사용 가능하면 true, 사용 불가하면 false. + */ + boolean isNicknameValid(String newNickname, String currentNickname) { + // 이미 자신이 사용 중인 닉네임이어서는 안됨 + if (currentNickname.equals(newNickname)) { + throw new BusinessException(ResponseCode.SAME_AS_CURRENT_NICKNAME); + } + + // 닉네임 글자수는 2글자부터 최대 10글자까지 + if (newNickname.length() < NICKNAME_MIN_LENGTH || newNickname.length() > NICKNAME_MAX_LENGTH) { + throw new BusinessException(ResponseCode.INVALID_NICKNAME_LENGTH); + } + + // 닉네임은 한글, 숫자, 영문만 입력할 수 있음 + if (!newNickname.matches(NICKNAME_PATTERN)) { + throw new BusinessException(ResponseCode.INVALID_NICKNAME_FORMAT); + } + + return !memberRepository.existsByNickname(newNickname); + } + + /** + * 회원 정보를 조회합니다. + * + * @param memberId 요청 회원의 ID + * @return 조회한 회원 정보를 담은 DTO + */ + public MemberInfoDto getMemberInfo(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ResponseCode.MEMBER_NOT_FOUND)); + return MemberInfoDto.from(member); + } } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/review/controller/ReviewController.java b/src/main/java/com/server/running_handai/domain/review/controller/ReviewController.java index a95ac8d..5034c1a 100644 --- a/src/main/java/com/server/running_handai/domain/review/controller/ReviewController.java +++ b/src/main/java/com/server/running_handai/domain/review/controller/ReviewController.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.review.controller; +import com.server.running_handai.domain.review.dto.MyReviewInfoDto; import com.server.running_handai.domain.review.dto.ReviewCreateResponseDto; import com.server.running_handai.domain.review.dto.ReviewInfoListDto; import com.server.running_handai.domain.review.dto.ReviewCreateRequestDto; @@ -14,9 +15,9 @@ 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.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -104,4 +105,23 @@ public ResponseEntity> deleteReview( return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, null)); } + @Operation(summary = "내 리뷰 조회", description = "회원이 작성한 리뷰를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "200", description = "성공 (리뷰 없음)") + }) + @GetMapping("/api/members/me/reviews") + public ResponseEntity>> getMyReviews( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + List responseData = reviewService.getMyReviews(memberId); + + if (responseData.isEmpty()) { + return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS_EMPTY_REVIEWS, responseData)); + } + + return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, responseData)); + } + } diff --git a/src/main/java/com/server/running_handai/domain/review/dto/MyReviewInfoDto.java b/src/main/java/com/server/running_handai/domain/review/dto/MyReviewInfoDto.java new file mode 100644 index 0000000..4d4c499 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/review/dto/MyReviewInfoDto.java @@ -0,0 +1,39 @@ +package com.server.running_handai.domain.review.dto; + +import com.server.running_handai.domain.course.entity.Course; +import com.server.running_handai.domain.review.entity.Review; +import java.time.format.DateTimeFormatter; + +public record MyReviewInfoDto( + long reviewId, + long courseId, + String courseName, + String thumbnailUrl, + String area, + int distance, + int duration, + int maxElevation, + double stars, + String contents, + String createdAt +) { + public static MyReviewInfoDto from(Review review) { + Course course = review.getCourse(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + String formattedCreatedAt = review.getCreatedAt().format(formatter); + + return new MyReviewInfoDto( + review.getId(), + course.getId(), + course.getName(), + (course.getCourseImage() != null) ? course.getCourseImage().getImgUrl() : null, + course.getArea().name(), + (int) course.getDistance(), + course.getDuration(), + (int) course.getMaxElevation().doubleValue(), + review.getStars(), + review.getContents(), + formattedCreatedAt + ); + } +} diff --git a/src/main/java/com/server/running_handai/domain/review/dto/ReviewInfoListDto.java b/src/main/java/com/server/running_handai/domain/review/dto/ReviewInfoListDto.java index 316870b..7c92d62 100644 --- a/src/main/java/com/server/running_handai/domain/review/dto/ReviewInfoListDto.java +++ b/src/main/java/com/server/running_handai/domain/review/dto/ReviewInfoListDto.java @@ -7,7 +7,7 @@ public record ReviewInfoListDto( int reviewCount, List reviewInfoDtos ) { - public static ReviewInfoListDto from(double starAverage, List reviewInfoDtos) { - return new ReviewInfoListDto(starAverage, reviewInfoDtos.size(), reviewInfoDtos); + public static ReviewInfoListDto from(double starAverage, int reviewCount, List reviewInfoDtos) { + return new ReviewInfoListDto(starAverage, reviewCount, reviewInfoDtos); } } diff --git a/src/main/java/com/server/running_handai/domain/review/repository/ReviewRepository.java b/src/main/java/com/server/running_handai/domain/review/repository/ReviewRepository.java index 1a2e6c2..174762f 100644 --- a/src/main/java/com/server/running_handai/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/server/running_handai/domain/review/repository/ReviewRepository.java @@ -19,14 +19,29 @@ public interface ReviewRepository extends JpaRepository { boolean existsByIdAndWriterId(Long reviewId, Long writerId); /** - * courseId로 리뷰를 랜덤으로 2개만 조회 + * courseId로 최신순 리뷰 2개 조회 */ - @Query(value = "SELECT * FROM review r WHERE r.course_id = :courseId ORDER BY RAND() LIMIT 2", nativeQuery = true) - List findRandom2ByCourseId(@Param("courseId") Long courseId); + @Query(value = "SELECT * FROM review r WHERE r.course_id = :courseId ORDER BY created_at DESC LIMIT 2", nativeQuery = true) + List findRecent2ByCourseId(@Param("courseId") Long courseId); /** * courseId에 해당하는 모든 리뷰의 평점(stars) 평균을 계산 */ @Query("SELECT AVG(r.stars) FROM Review r WHERE r.course.id = :courseId") Double findAverageStarsByCourseId(@Param("courseId") Long courseId); + + /** + * courseId로 조회한 리뷰의 전체 개수 조회 + */ + long countByCourseId(Long courseId); + + /** + * 특정 회원이 작성한 리뷰 조회 (연관 엔티티 동시 조회) + */ + @Query("SELECT r FROM Review r " + + "LEFT JOIN FETCH r.course c " + + "LEFT JOIN FETCH c.courseImage ci " + + "WHERE r.writer.id = :memberId " + + "ORDER BY r.createdAt DESC") + List findReviewsWithDetailsByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/server/running_handai/domain/review/service/ReviewService.java b/src/main/java/com/server/running_handai/domain/review/service/ReviewService.java index 4e056d9..e96b4ed 100644 --- a/src/main/java/com/server/running_handai/domain/review/service/ReviewService.java +++ b/src/main/java/com/server/running_handai/domain/review/service/ReviewService.java @@ -4,6 +4,7 @@ import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.member.entity.Member; import com.server.running_handai.domain.member.repository.MemberRepository; +import com.server.running_handai.domain.review.dto.MyReviewInfoDto; import com.server.running_handai.domain.review.dto.ReviewCreateResponseDto; import com.server.running_handai.domain.review.dto.ReviewInfoDto; import com.server.running_handai.domain.review.dto.ReviewInfoListDto; @@ -14,8 +15,10 @@ import com.server.running_handai.domain.review.repository.ReviewRepository; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; +import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -73,7 +76,7 @@ public ReviewInfoListDto findAllReviewsByCourse(Long courseId, Long memberId) { double averageStars = calculateAverageStars(courseId); List reviewInfoDtos = convertToReviewInfoDtos(reviewRepository.findAllByCourseId(courseId), memberId); - return ReviewInfoListDto.from(averageStars, reviewInfoDtos); + return ReviewInfoListDto.from(averageStars, reviewInfoDtos.size(), reviewInfoDtos); } /** @@ -163,4 +166,22 @@ public void deleteReview(Long reviewId, Long memberId) { reviewRepository.delete(review); } + /** + * 회원이 작성한 리뷰를 조회합니다. + * + * @param memberId 요청한 회원의 ID + * @return 내 리뷰 조회용 DTO + */ + public List getMyReviews(Long memberId) { + List reviews = reviewRepository.findReviewsWithDetailsByMemberId(memberId); + + if (reviews.isEmpty()) { + return Collections.emptyList(); + } + + return reviews.stream() + .map(MyReviewInfoDto::from) + .collect(Collectors.toList()); + } + } diff --git a/src/main/java/com/server/running_handai/domain/spot/client/SpotApiClient.java b/src/main/java/com/server/running_handai/domain/spot/client/SpotApiClient.java new file mode 100644 index 0000000..171fb2d --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/client/SpotApiClient.java @@ -0,0 +1,50 @@ +package com.server.running_handai.domain.spot.client; + +import com.server.running_handai.domain.spot.dto.SpotApiResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpotApiClient { + private final WebClient webClient; + + @Value("${external.api.spot.base-url}") + private String baseUrl; + + @Value("${external.api.spot.service-key}") + private String serviceKey; + + /** + * [국문 관광정보] 공통정보 조회 API를 요청합니다. + * + * @param contentId 장소 고유번호 + * @return SpotApiResponseDto + */ + public SpotApiResponseDto fetchSpotData(String contentId) { + // URL 생성 + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path("/detailCommon2") + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "runninghandai") + .queryParam("_type", "json") + .queryParam("contentId", contentId) + .queryParam("serviceKey", serviceKey); + + URI uri = builder.build(true).toUri(); + + // API 호출 + return webClient.get() + .uri(uri) + .retrieve() + .bodyToMono(SpotApiResponseDto.class) + .block(); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/client/SpotLocationApiClient.java b/src/main/java/com/server/running_handai/domain/spot/client/SpotLocationApiClient.java new file mode 100644 index 0000000..662921a --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/client/SpotLocationApiClient.java @@ -0,0 +1,71 @@ +package com.server.running_handai.domain.spot.client; + +import com.server.running_handai.domain.spot.dto.SpotLocationApiResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpotLocationApiClient { + private final WebClient webClient; + + @Value("${external.api.spot.base-url}") + private String baseUrl; + + @Value("${external.api.spot.service-key}") + private String serviceKey; + + @Value("${external.api.spot.radius}") + private String radius; + + /** + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청합니다. + * + * @param pageNo 현재 페이지 번호 + * @param numOfRows 한 페이지 결과 수 + * @param arrange 정렬 구분 (E: 거리순, S: 대표 이미지가 반드시 있는 거리순) + * @param lon 경도 (x) + * @param lat 위도 (y) + * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) + * @return SpotLocationResponseDto + */ + public SpotLocationApiResponseDto fetchSpotLocationData( + int pageNo, + int numOfRows, + String arrange, + double lon, + double lat, + int contentTypeId + ) { + // URL 생성 + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path("/locationBasedList2") + .queryParam("numOfRows", numOfRows) + .queryParam("pageNo", pageNo) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "runninghandai") + .queryParam("_type", "json") + .queryParam("arrange", arrange) + .queryParam("mapX", String.valueOf(lon)) + .queryParam("mapY", String.valueOf(lat)) + .queryParam("radius", radius) + .queryParam("contentTypeId", String.valueOf(contentTypeId)) + .queryParam("serviceKey", serviceKey); + + URI uri = builder.build(true).toUri(); + + // API 호출 + return webClient.get() + .uri(uri) + .retrieve() + .bodyToMono(SpotLocationApiResponseDto.class) + .block(); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/controller/SpotController.java b/src/main/java/com/server/running_handai/domain/spot/controller/SpotController.java new file mode 100644 index 0000000..6ad59fd --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/controller/SpotController.java @@ -0,0 +1,44 @@ +package com.server.running_handai.domain.spot.controller; + +import com.server.running_handai.domain.spot.dto.SpotDetailDto; +import com.server.running_handai.domain.spot.service.SpotService; +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +import static com.server.running_handai.global.response.ResponseCode.SUCCESS; + +@Slf4j +@RestController +@RequestMapping("/api/courses") +@RequiredArgsConstructor +public class SpotController { + private final SpotService spotService; + + @Operation(summary = "즐길거리 전체 조회", description = "특정 코스의 즐길거리 전체 정보를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스)"), + }) + @GetMapping("/{courseId}/spots") + public ResponseEntity> getSpotDetails( + @Parameter(description = "조회하려는 코스 ID", required = true) @PathVariable("courseId") Long courseId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = (customOAuth2User != null) ? customOAuth2User.getMember().getId() : null; + log.info("[즐길거리 전체 조회] courseId: {}, memberId: {}", courseId, memberId); + SpotDetailDto spotDetailDto = spotService.getSpotDetails(courseId); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, spotDetailDto)); + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java b/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java new file mode 100644 index 0000000..fe542e8 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java @@ -0,0 +1,24 @@ +package com.server.running_handai.domain.spot.controller; + +import com.server.running_handai.domain.spot.service.SpotDataService; +import com.server.running_handai.global.response.CommonResponse; +import com.server.running_handai.global.response.ResponseCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/courses") +@RequiredArgsConstructor +public class SpotDataController { + private final SpotDataService spotDataService; + + @PutMapping("/{courseId}/spots") + public ResponseEntity> updateSpots(@PathVariable Long courseId) { + spotDataService.updateSpots(courseId); + return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotApiResponseDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotApiResponseDto.java new file mode 100644 index 0000000..0cf1b7f --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotApiResponseDto.java @@ -0,0 +1,103 @@ +package com.server.running_handai.domain.spot.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.List; + +@Hidden +@Getter +@ToString +@NoArgsConstructor +public class SpotApiResponseDto { + + @JsonProperty("response") + private Response response; + + @Getter + @ToString + @NoArgsConstructor + public static class Response { + @JsonProperty("header") + private Header header; + + @JsonProperty("body") + private Body body; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Header { + @JsonProperty("resultCode") + private String resultCode; + + @JsonProperty("resultMsg") + private String resultMsg; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Body { + @JsonProperty("items") + private Items items; + + @JsonProperty("numOfRows") + private int numOfRows; + + @JsonProperty("pageNo") + private int pageNo; + + @JsonProperty("totalCount") + private int totalCount; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Items { + @JsonProperty("item") + private List itemList; + } + + /** + * [국문 관광정보] 공통정보 조회 + */ + @Getter + @ToString + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Item { + @JsonProperty("contentid") + private String spotExternalId; // 장소 고유번호 (Spot.externalId) + + @JsonProperty("title") + private String spotName; // 장소 이름 (Spot.name) + + @JsonProperty("overview") + private String spotDescription; // 장소 설명 (Spot.description) + + @JsonProperty("addr1") + private String spotAddress; // 장소 주소 (Spot.address) + + @JsonProperty("cat3") + private String spotCategoryNumber; // 장소 카테고리 (Spot.category) + + @JsonProperty("firstimage") + private String spotOriginalImage; // 대표 이미지 - 원본 (SpotImage.imageUrl) + + @JsonProperty("firstimage2") + private String spotThumbnailImage; // 대표 이미지 - 썸네일 (SpotImage.imageUrl) + + @JsonProperty("mapx") + private String spotLongitude; // 장소 경도 좌표 (Spot.lon) + + @JsonProperty("mapy") + private String spotLatitude; // 장소 위도 좌표 (Spot.lat) + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotDetailDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotDetailDto.java new file mode 100644 index 0000000..f81a2d8 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotDetailDto.java @@ -0,0 +1,17 @@ +package com.server.running_handai.domain.spot.dto; + +import java.util.List; + +public record SpotDetailDto ( + long courseId, + int spotCount, + List spots +) { + public static SpotDetailDto from(long courseId, List spots) { + return new SpotDetailDto( + courseId, + spots.size(), + spots + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotInfoDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotInfoDto.java new file mode 100644 index 0000000..621ee55 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotInfoDto.java @@ -0,0 +1,19 @@ +package com.server.running_handai.domain.spot.dto; + +import com.server.running_handai.domain.spot.entity.Spot; + +public record SpotInfoDto( + long spotId, + String name, + String description, + String imageUrl +) { + public static SpotInfoDto from(Spot spot) { + return new SpotInfoDto( + spot.getId(), + spot.getName(), + spot.getDescription(), + spot.getSpotImage() != null ? spot.getSpotImage().getImgUrl() : null + ); + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotLocationApiResponseDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotLocationApiResponseDto.java new file mode 100644 index 0000000..bc8e9cb --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotLocationApiResponseDto.java @@ -0,0 +1,80 @@ +package com.server.running_handai.domain.spot.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.List; + +@Hidden +@Getter +@ToString +@NoArgsConstructor +public class SpotLocationApiResponseDto { + + @JsonProperty("response") + private Response response; + + @Getter + @ToString + @NoArgsConstructor + public static class Response { + @JsonProperty("header") + private Header header; + + @JsonProperty("body") + private Body body; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Header { + @JsonProperty("resultCode") + private String resultCode; + + @JsonProperty("resultMsg") + private String resultMsg; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Body { + @JsonProperty("items") + private Items items; + + @JsonProperty("numOfRows") + private int numOfRows; + + @JsonProperty("pageNo") + private int pageNo; + + @JsonProperty("totalCount") + private int totalCount; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Items { + @JsonProperty("item") + private List itemList; + } + + /** + * [국문 관광정보] 위치기반 관광정보 조회 + */ + @Getter + @ToString + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Item { + + @JsonProperty("contentid") + private String spotExternalId; // 장소 고유번호 (Spot.externalId) + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/CourseSpot.java b/src/main/java/com/server/running_handai/domain/spot/entity/CourseSpot.java new file mode 100644 index 0000000..b7ded4c --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/entity/CourseSpot.java @@ -0,0 +1,40 @@ +package com.server.running_handai.domain.spot.entity; + +import com.server.running_handai.domain.course.entity.Course; +import com.server.running_handai.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "course_spot", uniqueConstraints = { + @UniqueConstraint( + name = "course_spot_uk", + columnNames = {"course_id", "spot_id"} + ) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CourseSpot extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "course_spot_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id", nullable = false) + private Course course; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spot_id", nullable = false) + private Spot spot; + + @Builder + public CourseSpot(Course course, Spot spot) { + this.course = course; + this.spot = spot; + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java new file mode 100644 index 0000000..afc4985 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java @@ -0,0 +1,73 @@ +package com.server.running_handai.domain.spot.entity; + +import com.server.running_handai.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "spot") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Spot extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "spot_id") + private Long id; + + @Column(name = "external_id", unique = true, nullable = false) + private String externalId; // 국문 관광정보 API의 장소 식별자 + + @Column(name = "name", nullable = false) + private String name; // 이름 + + @Column(name = "address", nullable = false) + private String address; // 주소 + + @Column(name = "description", columnDefinition = "TEXT") + private String description; // 설명 + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false) + private SpotCategory spotCategory; // 카테고리 + + @Column(name = "lat", nullable = false) + private double lat; // 위도 + + @Column(name = "lon", nullable = false) + private double lon; // 경도 + + // CourseSpot과 일대다 관계 + @OneToMany(mappedBy = "spot", cascade = CascadeType.ALL, orphanRemoval = true) + private List courseSpots = new ArrayList<>(); + + // SpotImage와 일대일 관계 + @OneToOne(mappedBy = "spot", cascade = CascadeType.ALL, orphanRemoval = true) + private SpotImage spotImage; + + @Builder + public Spot(String externalId, String name, String address, String description, + SpotCategory spotCategory, double lat, double lon) { + this.externalId = externalId; + this.name = name; + this.address = address; + this.description = description; + this.spotCategory = spotCategory; + this.lat = lat; + this.lon = lon; + } + + // ==== 연관관계 편의 메서드 ==== // + public void setSpotImage(SpotImage spotImage) { + this.spotImage = spotImage; + if (spotImage != null) { + spotImage.setSpot(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java b/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java new file mode 100644 index 0000000..060efc4 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java @@ -0,0 +1,43 @@ +package com.server.running_handai.domain.spot.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Getter +@RequiredArgsConstructor +public enum SpotCategory { + NATURE("자연관광지", List.of("A01010100", "A01010200", "A01010300", "A01010400", "A01010500", "A01010600", "A01010700", "A01010800", "A01010900", "A01011000", + "A01011100", "A01011200", "A01011300", "A01011400", "A01011600", "A01011700", "A01011800", "A01011900", "A01020100", "A01020200")), + HISTORY("역사관광지", List.of("A02010100", "A02010200", "A02010300", "A02010400", "A02010500", "A02010600", "A02010700", "A02010800", "A02010900", "A02011000")), + RECREATION("휴양관광지", List.of("A02020200", "A02020300", "A02020400", "A02020500", "A02020600", "A02020700", "A02020800")), + EXPERIENCE("체험관광지", List.of("A02030100", "A02030200", "A02030300", "A02030400", "A02030600")), + INDUSTRIAL("산업관광지", List.of("A02040400", "A02040600", "A02040800", "A02040900", "A02041000")), + ARCHITECTURE("건축조형물", List.of("A02050100", "A02050200", "A02050300", "A02050400", "A02050500", "A02050600")), + KOREAN_FOOD("한식", List.of("A05020100")), + WESTERN_FOOD("서양식", List.of("A05020200")), + JAPANESE_FOOD("일식", List.of("A05020300")), + CHINESE_FOOD("중식", List.of("A05020400")), + GLOBAL_FOOD("이색음식점", List.of("A05020700")), + CAFE("카페", List.of("A05020900")), + CLUB("클럽", List.of("A05021000")), + UNKNOWN("알수없음", List.of()); + + private final String description; + private final List categoryNumber; + + /** + * 주어진 Category Number를 포함하는 Category Enum을 찾아 반환합니다. + * + * @param categoryNumber 카테고리 번호 (예: "A01010100") + * @return 일치하는 Category Enum을 Optional로 감싸서 반환, 없으면 Optional.empty() + */ + public static Optional findByCategoryNumber(String categoryNumber) { + return Arrays.stream(SpotCategory.values()) + .filter(spotCategory -> spotCategory.getCategoryNumber().contains(categoryNumber)) + .findFirst(); + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/SpotImage.java b/src/main/java/com/server/running_handai/domain/spot/entity/SpotImage.java new file mode 100644 index 0000000..53b37bd --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/entity/SpotImage.java @@ -0,0 +1,41 @@ +package com.server.running_handai.domain.spot.entity; + +import com.server.running_handai.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "spot_image") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SpotImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "spot_img_id") + private Long spotImageId; + + @Column(name = "img_url", nullable = false) + private String imgUrl; // s3 url + + @Column(name = "original_url", nullable = false) + private String originalUrl; // [국문 관광정보 API]에서 제공하는 이미지 url + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spot_id", unique = true, nullable = false) + private Spot spot; + + @Builder + public SpotImage(String imgUrl, String originalUrl) { + this.imgUrl = imgUrl; + this.originalUrl = originalUrl; + } + + // ==== 연관관계 편의 메서드 ==== // + protected void setSpot(Spot spot) { + this.spot = spot; + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java new file mode 100644 index 0000000..57a2a39 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java @@ -0,0 +1,13 @@ +package com.server.running_handai.domain.spot.repository; + +import com.server.running_handai.domain.spot.entity.CourseSpot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CourseSpotRepository extends JpaRepository { + @Modifying + @Query("DELETE FROM CourseSpot cs WHERE cs.course.id = :courseId") + void deleteByCourseId(@Param("courseId") Long courseId); +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java new file mode 100644 index 0000000..d4cea41 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java @@ -0,0 +1,43 @@ +package com.server.running_handai.domain.spot.repository; + +import com.server.running_handai.domain.spot.dto.SpotInfoDto; +import com.server.running_handai.domain.spot.entity.Spot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; + +public interface SpotRepository extends JpaRepository { + /** + * DB에 동일한 ExternalId를 가진 Spot이 있다면 해당 Spot을 가져옵니다. + */ + List findByExternalIdIn(Collection externalIds); + + /** + * CourseId와 일치하는 Spot을 SpotImage와 함께 가져옵니다. + */ + @Query("SELECT s " + + "FROM Spot s " + + "LEFT JOIN FETCH s.spotImage " + + "JOIN CourseSpot cs ON cs.spot = s " + + "WHERE cs.course.id = :courseId") + List findByCourseIdWithSpotImage(@Param("courseId") Long courseId); + + /** + * CourseId와 일치하는 Spot을 SpotImage와 함께 랜덤으로 3개 가져옵니다. + */ + @Query(value = "SELECT " + + " s.spot_id AS spotId, " + + " s.name, " + + " s.description, " + + " si.img_url As imageUrl " + + "FROM spot s " + + "LEFT JOIN spot_image si ON s.spot_id = si.spot_id " + + "JOIN course_spot cs ON cs.spot_id = s.spot_id " + + "WHERE cs.course_id = :courseId " + + "ORDER BY RAND() LIMIT 3", + nativeQuery = true) + List findRandom3ByCourseId(@Param("courseId") Long courseId); +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java new file mode 100644 index 0000000..223bec3 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java @@ -0,0 +1,320 @@ +package com.server.running_handai.domain.spot.service; + +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.repository.CourseRepository; +import com.server.running_handai.domain.course.repository.TrackPointRepository; +import com.server.running_handai.domain.course.service.FileService; +import com.server.running_handai.domain.spot.client.SpotApiClient; +import com.server.running_handai.domain.spot.client.SpotLocationApiClient; +import com.server.running_handai.domain.spot.dto.SpotApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotLocationApiResponseDto; +import com.server.running_handai.domain.spot.entity.SpotCategory; +import com.server.running_handai.domain.spot.entity.CourseSpot; +import com.server.running_handai.domain.spot.entity.Spot; +import com.server.running_handai.domain.spot.entity.SpotImage; +import com.server.running_handai.domain.spot.repository.CourseSpotRepository; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpotDataService { + private final SpotLocationApiClient spotLocationApiClient; + private final SpotApiClient spotApiClient; + private final CourseRepository courseRepository; + private final TrackPointRepository trackPointRepository; + private final SpotRepository spotRepository; + private final CourseSpotRepository courseSpotRepository; + private final FileService fileService; + + // [국문 관광정보] 관광 타입 + private static final int TOURIST_SPOT_TYPE = 12; + private static final int RESTAURANT_TYPE = 39; + + /** + * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 API와 공통정보조회 API를 통해 가져옵니다. + * + * @param courseId 코스 id + */ + @Transactional + public void updateSpots(Long courseId) { + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + + List trackPoints = trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId()); + TrackPoint startPoint = trackPoints.getFirst(); + TrackPoint endPoint = trackPoints.getLast(); + + // 1. 장소 externalId 수집 + Set externalIds = fetchSpotsByLocationInParallel(startPoint, endPoint); + log.info("[즐길거리 수정] 수집된 고유 externalId 개수: {}", externalIds.size()); + + // 2. 수집된 externalId로 장소 정보 수집 + // 이미 externalId에 해당하는 Spot 정보가 있을 경우, 해당 정보를 가져옴 + List existingSpots = spotRepository.findByExternalIdIn(externalIds); + List spots = new ArrayList<>(existingSpots); + + Set existingIds = existingSpots.stream() + .map(Spot::getExternalId) + .collect(Collectors.toSet()); + externalIds.removeAll(existingIds); + + // 정보가 없는 externalId만 모아 공통정보 조회 API를 병렬로 호출 + List items = fetchSpotsInParallel(externalIds); + + // Spot, SpotImage 객체 생성 + for (SpotApiResponseDto.Item item : items) { + Optional spotOptional = createSpot(item); + spotOptional.ifPresent(spot -> { + SpotImage spotImage = createSpotImage(item); + if (spotImage != null) { + spot.setSpotImage(spotImage); + } + spots.add(spot); + }); + } + + // 3. Course와 Spot의 연관관계 초기화 + courseSpotRepository.deleteByCourseId(courseId); + log.info("[즐길거리 수정] 기존 즐길거리 데이터 삭제 완료: courseId={}", courseId); + + // 4. Spot, CourseSpot DB 저장 + List newSpots = spotRepository.saveAll(spots); + + List courseSpots = newSpots.stream() + .map(spot -> + CourseSpot.builder().course(course).spot(spot).build()) + .collect(Collectors.toList()); + + courseSpotRepository.saveAll(courseSpots); + log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 개수={}", courseId, spots.size()); + } + + /** + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. + * API 응답값의 spotExternalId가 유효하지 않을 경우, Set에 포함하지 않습니다. + * + * @param lon 경도 (x) + * @param lat 위도 (y) + * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) + * @return 유효한 externalId의 Set + */ + private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { + SpotLocationApiResponseDto spotLocationApiResponseDto = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, contentTypeId); + + if (spotLocationApiResponseDto == null || spotLocationApiResponseDto.getResponse() == null || + spotLocationApiResponseDto.getResponse().getBody() == null || spotLocationApiResponseDto.getResponse().getBody().getItems() == null) { + return Collections.emptySet(); + } + + List items = spotLocationApiResponseDto.getResponse().getBody().getItems().getItemList(); + + if (items == null || items.isEmpty()) { + return Collections.emptySet(); + } + + return items.stream() + .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) + .map(SpotLocationApiResponseDto.Item::getSpotExternalId) + .collect(Collectors.toSet()); + } + + /** + * [국문 관광정보] 공통정보 조회 API를 요청해 externalId에 대한 SpotApiResponseDto.Item을 반환합니다. + * + * @param externalId 장소 고유번호 + * @return SpotApiResponseDto.Item 정보, 없으면 Optional.empty() + */ + public Optional fetchSpot(String externalId) { + SpotApiResponseDto spotApiResponseDto = spotApiClient.fetchSpotData(externalId); + + if (spotApiResponseDto == null || spotApiResponseDto.getResponse() == null || + spotApiResponseDto.getResponse().getBody() == null || spotApiResponseDto.getResponse().getBody().getItems() == null) { + return Optional.empty(); + } + + List items = spotApiResponseDto.getResponse().getBody().getItems().getItemList(); + + if (items == null || items.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(items.getFirst()); + } + + /** + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청을 병렬로 요청해 4가지 조건(시작점, 도착점, 관광지, 음식점)의 externalId를 수집합니다. + * + * @param startPoint 시작점 + * @param endPoint 도착점 + * @return 수집한 중복 없는 externalId Set + */ + private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackPoint endPoint) { + List>> tasks = List.of( + () -> fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), TOURIST_SPOT_TYPE), + () -> fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), TOURIST_SPOT_TYPE), + () -> fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), RESTAURANT_TYPE), + () -> fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), RESTAURANT_TYPE) + ); + + return tasks.parallelStream() + // 각 task 실행 후 결과 수집 + .map(task -> { + try { + return task.call(); + } catch (Exception e) { + log.error("[즐길거리 수정] 위치기반 관광정보 조회 API 호출 중 오류 발생: error={}", e.getMessage()); + return Collections.emptyList(); + } + }) + .flatMap(Collection::stream) + .collect(Collectors.toSet()); + } + + /** + * [국문 관광정보] 공통정보 조회 API를 요청을 병렬로 요청해 externalId의 관광 정보를 수집합니다. + * + * @param externalIds 처리할 externalId Set + * @return 생성된 Spot 객체들의 List + */ + private List fetchSpotsInParallel(Set externalIds) { + return externalIds.parallelStream() + .map(externalId -> { + try { + return fetchSpot(externalId); + } catch (Exception e) { + log.error("[즐길거리 수정] 공통정보 조회 API 호출 중 오류 발생: externalId={}, error={}", externalId, e.getMessage()); + return Optional.empty(); + } + }) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * Spot 객체를 생성합니다. + * 이때 SpotApiResponse,Item의 필드를 검사하여 유효할 경우에만 Spot을 생성합니다. + * + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 생성된 Optional Spot, 유효하지 않으면 Optional.empty + */ + private Optional createSpot(SpotApiResponseDto.Item item) { + if (!isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) { + return Optional.empty(); + } + + if (!isFieldValid(item.getSpotName(), "spotName", item.getSpotExternalId())) { + return Optional.empty(); + } + + if (!isFieldValid(item.getSpotDescription(), "spotDescription", item.getSpotExternalId())) { + return Optional.empty(); + } + + if (!isFieldValid(item.getSpotAddress(), "spotAddress", item.getSpotExternalId())) { + return Optional.empty(); + } + + if (!isFieldValid(item.getSpotCategoryNumber(), "spotCategoryNumber", item.getSpotExternalId())) { + return Optional.empty(); + } + + if (!isFieldValid(item.getSpotLongitude(), "spotLongitude", item.getSpotExternalId()) + || !isFieldDouble(item.getSpotLongitude(), "spotLongitude", item.getSpotExternalId())) { + return Optional.empty(); + } + + if (!isFieldValid(item.getSpotLatitude(), "spotLatitude", item.getSpotExternalId()) + || !isFieldDouble(item.getSpotLatitude(), "spotLatitude", item.getSpotExternalId())) { + return Optional.empty(); + } + + Spot spot = Spot.builder() + .externalId(item.getSpotExternalId()) + .name(item.getSpotName()) + .address(item.getSpotAddress()) + .description(item.getSpotDescription()) + .spotCategory(SpotCategory.findByCategoryNumber(item.getSpotCategoryNumber()) + .orElse(SpotCategory.UNKNOWN)) + .lat(Double.parseDouble(item.getSpotLatitude())) + .lon(Double.parseDouble(item.getSpotLongitude())) + .build(); + + return Optional.of(spot); + } + + /** + * SpotImage 객체를 생성합니다. + * originalImage를 우선 저장하고, originalImage이 없는 경우 thumbnailImage를 저장합니다. + * + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 SpotImage 객체, 없으면 null + */ + private SpotImage createSpotImage(SpotApiResponseDto.Item item) { + String originalImage = item.getSpotOriginalImage(); + String thumbnailImage = item.getSpotThumbnailImage(); + + if (isFieldValid(originalImage, "spotOriginalImage", item.getSpotExternalId())) { + String s3FileUrl = fileService.uploadFileByUrl(originalImage, "spot"); + return SpotImage.builder() + .imgUrl(s3FileUrl) + .originalUrl(originalImage) + .build(); + } else if (isFieldValid(thumbnailImage, "spotThumbnailImage", item.getSpotExternalId())) { + String s3FileUrl = fileService.uploadFileByUrl(thumbnailImage, "spot"); + return SpotImage.builder() + .imgUrl(s3FileUrl) + .originalUrl(thumbnailImage) + .build(); + } + return null; + } + + /** + * API 응답값의 필드가 null이거나 빈 문자열인지 검사합니다. + * null 또는 빈 문자열일 경우 객체를 생성하지 않고 넘어갑니다. + * + * @param value 확인할 문자열 + * @param fieldName 필드 이름 (로그용) + * @param externalId 장소 고유번호 (로그용) + * @return null 혹은 빈 문자열인 경우 false, 아니면 true + */ + private boolean isFieldValid(String value, String fieldName, String externalId) { + if (value == null || value.isBlank()) { + log.warn("[즐길거리 수정] API 응답값 필드가 null 또는 빈 문자열이어서 건너뜀: externalId={}, fieldName={}", externalId, fieldName); + return false; + } + + return true; + } + + /** + * API 응답값에서 문자열로 들어오지만 Double로 저장되어야 하는 필드가 제대로 변환되는지 검사합니다. + * 변환되지 않는 경우 객체를 생성하지 않고 넘어갑니다. + * + * @param doubleString 확인할 문자열 + * @param fieldName 필드 이름 (로그용) + * @param externalId 장소 고유번호 (로그용) + * @return Double로 변환되면 true, 아니면 false + */ + private boolean isFieldDouble(String doubleString, String fieldName, String externalId) { + try { + Double.parseDouble(doubleString); + return true; + } catch (NumberFormatException e) { + log.warn("[즐길거리 수정] API 응답값 필드가 Double로 변환되지 않아 건너뜀: externalId={}, fieldName={}", externalId, fieldName); + return false; + } + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/service/SpotService.java b/src/main/java/com/server/running_handai/domain/spot/service/SpotService.java new file mode 100644 index 0000000..9898c83 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotService.java @@ -0,0 +1,44 @@ +package com.server.running_handai.domain.spot.service; + +import com.server.running_handai.domain.course.entity.Course; +import com.server.running_handai.domain.course.repository.CourseRepository; +import com.server.running_handai.domain.spot.dto.SpotDetailDto; +import com.server.running_handai.domain.spot.dto.SpotInfoDto; +import com.server.running_handai.domain.spot.entity.Spot; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SpotService { + private final CourseRepository courseRepository; + private final SpotRepository spotRepository; + + /** + * 코스에 해당되는 즐길거리를 전체 조회합니다. + * + * @param courseId 조회하려는 코스의 ID + * @return 조회된 즐길거리 목록 DTO + */ + public SpotDetailDto getSpotDetails(Long courseId) { + if (!courseRepository.existsById(courseId)) { + throw new BusinessException(ResponseCode.COURSE_NOT_FOUND); + } + List spots = spotRepository.findByCourseIdWithSpotImage(courseId); + + List spotInfoDtos = spots.stream() + .map(SpotInfoDto::from) + .toList(); + + return SpotDetailDto.from(courseId, spotInfoDtos); + } +} diff --git a/src/main/java/com/server/running_handai/global/config/WebClientConfig.java b/src/main/java/com/server/running_handai/global/config/WebClientConfig.java index aa22096..cebcb2c 100644 --- a/src/main/java/com/server/running_handai/global/config/WebClientConfig.java +++ b/src/main/java/com/server/running_handai/global/config/WebClientConfig.java @@ -11,5 +11,4 @@ public class WebClientConfig { public WebClient webClient(WebClient.Builder builder) { return builder.build(); } - } 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 b45c9a2..f659431 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 @@ -16,6 +16,7 @@ public enum ResponseCode { SUCCESS_BOOKMARK_CREATE(OK, "북마크 등록 완료했습니다."), SUCCESS_BOOKMARK_DELETE(OK, "북마크 해제 완료했습니다."), SUCCESS_EMPTY_REVIEWS(OK, "리뷰 조회 결과가 없습니다."), + SUCCESS_EMPTY_BOOKMARKS(OK, "북마크한 코스가 없습니다."), /** 비즈니스 에러 코드 */ // BAD_REQUEST (400) @@ -28,6 +29,9 @@ public enum ResponseCode { INVALID_REVIEW_STARS(BAD_REQUEST, "별점은 0.5점 단위여야합니다."), EMPTY_REVIEW_CONTENTS(BAD_REQUEST, "리뷰 내용은 비워둘 수 없습니다"), BAD_REQUEST_STATE_PARAMETER(BAD_REQUEST, "로그인 요청 시 유효한 state 값이 필요합니다."), + INVALID_NICKNAME_LENGTH(BAD_REQUEST, "닉네임은 2글자부터 10글자까지 입력할 수 있습니다."), + INVALID_NICKNAME_FORMAT(BAD_REQUEST, "닉네임은 영문, 한글, 숫자만 입력할 수 있습니다."), + SAME_AS_CURRENT_NICKNAME(BAD_REQUEST, "현재 사용 중인 닉네임과 동일합니다."), // UNAUTHORIZED (401) INVALID_ACCESS_TOKEN(UNAUTHORIZED, "유효하지 않은 액세스 토큰입니다."), @@ -46,17 +50,21 @@ public enum ResponseCode { BOOKMARK_NOT_FOUND(NOT_FOUND, "찾을 수 없는 북마크입니다."), REVIEW_NOT_FOUND(NOT_FOUND, "찾을 수 없는 리뷰입니다."), + // CONFLICT (409) + DUPLICATE_NICKNAME(CONFLICT, "이미 사용 중인 닉네임입니다."), + /** 시스템 및 공통 예외용 에러 코드 */ // BAD_REQUEST (400) ILLEGAL_ARGUMENT(BAD_REQUEST, "잘못된 인자 값입니다."), - METHOD_ARGUMENT_NOT_VALID(BAD_REQUEST, "유효하지 않은 인자 값입니다."), HTTP_MESSAGE_NOT_READABLE(BAD_REQUEST, "잘못된 요청 형식입니다."), MISSING_SERVLET_REQUEST_PARAMETER(BAD_REQUEST, "필수 요청 매개변수가 누락되었습니다."), ARGUMENT_TYPE_MISMATCH(BAD_REQUEST, "요청 매개변수의 타입이 올바르지 않습니다."), OPENAI_RESPONSE_INVALID(BAD_REQUEST, "OPEN AI 응답값이 유효하지 않습니다."), + INVALID_INPUT_VALUE(BAD_REQUEST, "유효하지 않은 입력 값입니다."), // NOT_FOUND (404) RESOURCE_NOT_FOUND(NOT_FOUND, "존재하지 않는 리소스입니다."), + TRACK_POINTS_NOT_FOUND(NOT_FOUND, "파싱된 트랙 포인트가 없습니다."), // METHOD_NOT_ALLOWED (405) HTTP_REQUEST_METHOD_NOT_SUPPORTED(METHOD_NOT_ALLOWED, "잘못된 HTTP Method 요청입니다."), @@ -68,7 +76,8 @@ public enum ResponseCode { FILE_DELETE_FAILED(INTERNAL_SERVER_ERROR, "파일 삭제를 실패했습니다."), GPX_FILE_PARSE_FAILED(INTERNAL_SERVER_ERROR, "GPX 파일 파싱을 실패했습니다"), ADDRESS_PARSE_FAILED(INTERNAL_SERVER_ERROR, "주소 파싱을 실패했습니다"), - PRESIGEND_URL_FAILED(INTERNAL_SERVER_ERROR, "Presigned Url 발급을 실패했습니다."); + PRESIGEND_URL_FAILED(INTERNAL_SERVER_ERROR, "Presigned Url 발급을 실패했습니다."), + UNSUPPORTED_FILE_TYPE(INTERNAL_SERVER_ERROR, "지원하지 않는 파일 Content Type입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/server/running_handai/global/response/exception/GlobalExceptionHandler.java b/src/main/java/com/server/running_handai/global/response/exception/GlobalExceptionHandler.java index d58ed8c..66c2574 100644 --- a/src/main/java/com/server/running_handai/global/response/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/server/running_handai/global/response/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.server.running_handai.global.response.CommonResponse; import com.server.running_handai.global.response.ResponseCode; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -27,10 +28,11 @@ public ResponseEntity> handleCustomException(BusinessException /** * BAD_REQUEST (400) * IllegalArgumentException: 사용자가 값을 잘못 입력한 경우 - * MethodArgumentNotValidException: 전달된 값이 유효하지 않은 경우 + * MethodArgumentNotValidException: 전달된 값이 유효하지 않은 경우 (@Valid) * HttpMessageNotReadableException: 잘못된 형식으로 요청할 경우 * MissingServletRequestParameterException: 필수 요청 매개변수가 누락된 경우 * MethodArgumentTypeMismatchException: 요청 매개변수의 타입 변환을 실패한 경우 + * ConstraintViolationException: 전달된 값이 유효하지 않은 경우 (@Validated) */ @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { @@ -40,7 +42,7 @@ public ResponseEntity> handleIllegalArgumentException(IllegalA @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleMethodArgumentNotValidException( MethodArgumentNotValidException e) { - return getErrorResponse(e, ResponseCode.METHOD_ARGUMENT_NOT_VALID); + return getErrorResponse(e, ResponseCode.INVALID_INPUT_VALUE); } @ExceptionHandler(HttpMessageNotReadableException.class) @@ -61,6 +63,12 @@ public ResponseEntity> handleMethodArgumentTypeMismatchExcepti return getErrorResponse(e, ResponseCode.ARGUMENT_TYPE_MISMATCH); } + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException( + ConstraintViolationException e) { + return getErrorResponse(e, ResponseCode.INVALID_INPUT_VALUE); + } + /** * METHOD_NOT_ALLOWED (405) * HttpRequestMethodNotSupportedException: 잘못된 Http Method를 가지고 요청할 경우 diff --git a/src/main/java/com/server/running_handai/domain/course/util/TrackPointSimplificationUtil.java b/src/main/java/com/server/running_handai/global/util/TrackPointSimplificationUtil.java similarity index 97% rename from src/main/java/com/server/running_handai/domain/course/util/TrackPointSimplificationUtil.java rename to src/main/java/com/server/running_handai/global/util/TrackPointSimplificationUtil.java index c27e2f5..02d873f 100644 --- a/src/main/java/com/server/running_handai/domain/course/util/TrackPointSimplificationUtil.java +++ b/src/main/java/com/server/running_handai/global/util/TrackPointSimplificationUtil.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.util; +package com.server.running_handai.global.util; import com.server.running_handai.domain.course.dto.SequenceTrackPointDto; import org.locationtech.jts.geom.Coordinate; diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 8189988..2eddf2b 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -70,7 +70,12 @@ spring: external: api: durunubi: + base-url: http://apis.data.go.kr/B551011/Durunubi service-key: "test-service-key" + spot: + base-url: http://apis.data.go.kr/B551011/KorService2 + service-key: "test-spot-service-key" + radius: 50000 jwt: secret-key: "test-jwt-secret-key" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d34d0f1..9d22e89 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -76,6 +76,7 @@ spring: servlet: multipart: max-file-size: 5MB + max-request-size: 5MB server: port: ${PORT:8080} @@ -91,6 +92,10 @@ external: durunubi: base-url: http://apis.data.go.kr/B551011/Durunubi service-key: ${DURUNUBI_SERVICE_KEY} + spot: + base-url: http://apis.data.go.kr/B551011/KorService2 + service-key: ${SPOT_SERVICE_KEY} + radius: 50000 # [국문 관광정보] 위치기반 관광정보 조회 API 거리 반경 (50000m = 5km) springdoc: default-produces-media-type: application/json @@ -128,4 +133,4 @@ app: swagger: server: local: http://localhost:8080 - prod: https://api.runninghandai.com \ No newline at end of file + prod: https://api.runninghandai.com diff --git a/src/test/java/com/server/running_handai/domain/bookmark/service/BookmarkServiceTest.java b/src/test/java/com/server/running_handai/domain/bookmark/service/BookmarkServiceTest.java index 55d3a58..76d9af8 100644 --- a/src/test/java/com/server/running_handai/domain/bookmark/service/BookmarkServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/bookmark/service/BookmarkServiceTest.java @@ -4,25 +4,46 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import com.server.running_handai.domain.bookmark.dto.BookmarkedCourseInfoDto; import com.server.running_handai.domain.bookmark.entity.Bookmark; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; +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.CourseLevel; import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import com.server.running_handai.domain.member.entity.Member; import com.server.running_handai.domain.member.repository.MemberRepository; +import io.swagger.v3.oas.annotations.Parameter; +import java.util.Collections; +import java.util.List; import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +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.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; @ActiveProfiles("test") @ExtendWith(MockitoExtension.class) @@ -192,4 +213,78 @@ void deleteBookmark_fail_courseNotFound() { // then assertThat(exception.getResponseCode()).isEqualTo(ResponseCode.COURSE_NOT_FOUND); } + + @Nested + @DisplayName("북마크한 코스 조회 테스트") + class GetBookmarkedCourseTest { + + private static Stream provideAreaArguments() { + return Stream.of( + Arguments.of((Object) null), + Arguments.of(Area.HAEUN_GWANGAN) + ); + } + + @ParameterizedTest + @MethodSource("provideAreaArguments") + @DisplayName("북마크한 코스 조회 성공 - 결과 있음") + void getBookmarkedCourse_success_hasContent(Area area) { + // given + Long memberId = 1L; + BookmarkedCourseInfoDto dto1 = mock(BookmarkedCourseInfoDto.class); + BookmarkedCourseInfoDto dto2 = mock(BookmarkedCourseInfoDto.class); + List expectedDtos = List.of(dto1, dto2); + + if (area == null) { + given(bookmarkRepository.findBookmarkedCoursesByMemberId(memberId)).willReturn(expectedDtos); + } else { + given(bookmarkRepository.findBookmarkedCoursesByMemberIdAndArea(memberId, area)).willReturn(expectedDtos); + } + + // when + List actualDtos = + bookmarkService.getBookmarkedCoursesByMemberAndArea(memberId, area); + + // then + assertThat(actualDtos.size()).isEqualTo(expectedDtos.size()); + assertThat(actualDtos).isEqualTo(expectedDtos); + + if (area == null) { + verify(bookmarkRepository).findBookmarkedCoursesByMemberId(memberId); + verify(bookmarkRepository, never()).findBookmarkedCoursesByMemberIdAndArea(any(), any()); + } else { + verify(bookmarkRepository, never()).findBookmarkedCoursesByMemberId(any()); + verify(bookmarkRepository).findBookmarkedCoursesByMemberIdAndArea(memberId, area); + } + } + + @ParameterizedTest + @MethodSource("provideAreaArguments") + @DisplayName("북마크한 코스 조회 성공 - 결과 없음") + void getBookmarkedCourse_success_noContent(Area area) { + // given + Long memberId = 1L; + + if (area == null) { + given(bookmarkRepository.findBookmarkedCoursesByMemberId(memberId)).willReturn(Collections.emptyList()); + } else { + given(bookmarkRepository.findBookmarkedCoursesByMemberIdAndArea(memberId, area)).willReturn(Collections.emptyList()); + } + + // when + List result = + bookmarkService.getBookmarkedCoursesByMemberAndArea(memberId, area); + + // then + assertThat(result).isEmpty(); + + if (area == null) { + verify(bookmarkRepository).findBookmarkedCoursesByMemberId(memberId); + verify(bookmarkRepository, never()).findBookmarkedCoursesByMemberIdAndArea(any(), any()); + } else { + verify(bookmarkRepository, never()).findBookmarkedCoursesByMemberId(any()); + verify(bookmarkRepository).findBookmarkedCoursesByMemberIdAndArea(memberId, area); + } + } + } } \ No newline at end of file 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 d8cf3a4..3b92c7c 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 @@ -15,7 +15,7 @@ import static org.mockito.Mockito.verify; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; -import com.server.running_handai.domain.course.dto.BookmarkCountDto; +import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; 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; @@ -37,6 +37,9 @@ import com.server.running_handai.domain.review.entity.Review; import com.server.running_handai.domain.review.repository.ReviewRepository; 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.entity.Spot; +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.time.LocalDateTime; @@ -81,6 +84,9 @@ class CourseServiceTest { @Mock private ReviewRepository reviewRepository; + @Mock + private SpotRepository spotRepository; + @Mock private ReviewService reviewService; @@ -145,7 +151,7 @@ void findCourses_success_forMember(CourseFilter filterType, CourseFilterRequestD assertThat(details.courseId()).isEqualTo(COURSE_ID); assertThat(details.courseName()).isEqualTo(course.getName()); - assertThat(details.distance()).isEqualTo(course.getDistance()); + assertThat(details.distance()).isEqualTo((int) course.getDistance()); assertThat(details.duration()).isEqualTo(course.getDuration()); assertThat(details.maxElevation()).isEqualTo((int) course.getMaxElevation().doubleValue()); assertThat(details.thumbnailUrl()).isEqualTo("thumbnailUrl"); @@ -200,7 +206,7 @@ void findCourses_success_forGuest(CourseFilter filterType, CourseFilterRequestDt assertThat(details.courseId()).isEqualTo(COURSE_ID); assertThat(details.courseName()).isEqualTo(course.getName()); - assertThat(details.distance()).isEqualTo(course.getDistance()); + assertThat(details.distance()).isEqualTo((int) course.getDistance()); assertThat(details.duration()).isEqualTo(course.getDuration()); assertThat(details.maxElevation()).isEqualTo((int) course.getMaxElevation().doubleValue()); assertThat(details.thumbnailUrl()).isEqualTo("thumbnailUrl"); @@ -281,7 +287,7 @@ void findCourseDetails_success_forMember() { assertNotNull(result); assertThat(result.courseId()).isEqualTo(COURSE_ID); assertThat(result.courseName()).isEqualTo(course.getName()); - assertThat(result.distance()).isEqualTo(course.getDistance()); + assertThat(result.distance()).isEqualTo((int) course.getDistance()); assertThat(result.duration()).isEqualTo(course.getDuration()); assertThat(result.minElevation()).isEqualTo((int) course.getMinElevation().doubleValue()); assertThat(result.maxElevation()).isEqualTo((int) course.getMaxElevation().doubleValue()); @@ -318,7 +324,7 @@ void findCourseDetails_success_forGuest() { assertNotNull(result); assertThat(result.courseId()).isEqualTo(COURSE_ID); assertThat(result.courseName()).isEqualTo(course.getName()); - assertThat(result.distance()).isEqualTo(course.getDistance()); + assertThat(result.distance()).isEqualTo((int) course.getDistance()); assertThat(result.duration()).isEqualTo(course.getDuration()); assertThat(result.minElevation()).isEqualTo((int)course.getMinElevation().doubleValue()); assertThat(result.maxElevation()).isEqualTo((int)course.getMaxElevation().doubleValue()); @@ -439,6 +445,12 @@ private Review createMockReview(Long reviewId, double stars, String contents) { return review; } + private Spot createMockSpot(Long spotId) { + Spot spot = Spot.builder().build(); + ReflectionTestUtils.setField(spot, "id", spotId); + return spot; + } + private static Stream memberAndGuestCases() { return Stream.of( Arguments.of(1L, true), // 회원이면 memberId=1L, isMyReview=true @@ -458,35 +470,51 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { Review review2 = createMockReview(2L, 5.0, "review2"); List reviews = List.of(review1, review2); - assertThat(review1.getStars()).isEqualTo(4.0); - assertThat(review2.getStars()).isEqualTo(5.0); - List reviewInfoDtos = List.of( ReviewInfoDto.from(review1, isMyReview), ReviewInfoDto.from(review2, isMyReview) ); + Spot spot1 = createMockSpot(101L); + Spot spot2 = createMockSpot(102L); + Spot spot3 = createMockSpot(103L); + + List spotInfoDtos = List.of( + new SpotInfoDto(101L, "Spot1", "Description1", "http://mock-image-url"), + new SpotInfoDto(102L, "Spot2", "Description2", "http://mock-image-url"), + new SpotInfoDto(103L, "Spot3", "Description3", "http://mock-image-url") + ); + given(courseRepository.findById(courseId)).willReturn(Optional.of(course)); - given(reviewRepository.findRandom2ByCourseId(courseId)).willReturn(reviews); + given(reviewRepository.findRecent2ByCourseId(courseId)).willReturn(reviews); + given(reviewRepository.countByCourseId(courseId)).willReturn(3L); + given(reviewService.calculateAverageStars(courseId)).willReturn(4.2); given(reviewService.convertToReviewInfoDtos(reviews, memberId)).willReturn(reviewInfoDtos); - given(reviewService.calculateAverageStars(courseId)).willReturn(4.5); + given(spotRepository.findRandom3ByCourseId(courseId)).willReturn(spotInfoDtos); // when CourseSummaryDto result = courseService.getCourseSummary(courseId, memberId); // then assertThat(result).isNotNull(); - assertThat(result.distance()).isEqualTo(course.getDistance()); + assertThat(result.distance()).isEqualTo((int) course.getDistance()); assertThat(result.duration()).isEqualTo(course.getDuration()); - assertThat(result.maxElevation()).isEqualTo(course.getMaxElevation()); - assertThat(result.reviewInfoListDto().starAverage()).isEqualTo(4.5); - assertThat(result.reviewInfoListDto().reviewInfoDtos().size()).isEqualTo(2); - assertThat(result.reviewInfoListDto().reviewInfoDtos().getFirst().reviewId()).isEqualTo(review1.getId()); - assertThat(result.reviewInfoListDto().reviewInfoDtos().getLast().reviewId()).isEqualTo(review2.getId()); + assertThat(result.maxElevation()).isEqualTo((int) course.getMaxElevation().doubleValue()); + assertThat(result.starAverage()).isEqualTo(4.2); + assertThat(result.reviewCount()).isEqualTo(3L); + assertThat(result.reviews().getFirst().reviewId()).isEqualTo(review1.getId()); + assertThat(result.reviews().getLast().reviewId()).isEqualTo(review2.getId()); + assertThat(result.spots().size()).isEqualTo(3); + assertThat(result.spots().get(0).spotId()).isEqualTo(spot1.getId()); + assertThat(result.spots().get(1).spotId()).isEqualTo(spot2.getId()); + assertThat(result.spots().get(2).spotId()).isEqualTo(spot3.getId()); verify(courseRepository).findById(courseId); - verify(reviewRepository).findRandom2ByCourseId(courseId); + verify(reviewRepository).findRecent2ByCourseId(courseId); + verify(reviewRepository).countByCourseId(courseId); + verify(reviewService).calculateAverageStars(courseId); verify(reviewService).convertToReviewInfoDtos(reviews, memberId); + verify(spotRepository).findRandom3ByCourseId(courseId); } @Test diff --git a/src/test/java/com/server/running_handai/domain/member/service/MemberServiceTest.java b/src/test/java/com/server/running_handai/domain/member/service/MemberServiceTest.java new file mode 100644 index 0000000..3924cee --- /dev/null +++ b/src/test/java/com/server/running_handai/domain/member/service/MemberServiceTest.java @@ -0,0 +1,227 @@ +package com.server.running_handai.domain.member.service; + +import com.server.running_handai.domain.member.dto.MemberUpdateRequestDto; +import com.server.running_handai.domain.member.dto.MemberUpdateResponseDto; +import com.server.running_handai.domain.member.entity.Member; +import com.server.running_handai.domain.member.repository.MemberRepository; +import com.server.running_handai.global.response.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static com.server.running_handai.global.response.ResponseCode.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; + +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + @Nested + @DisplayName("닉네임 유효성 검증 테스트") + class NicknameValidationTest { + + /** + * [닉네임 유효성 검증] 실패 + * 1. 현재 자신의 닉네임과 동일한 경우 + */ + @Test + @DisplayName("닉네임 유효성 검증 실패 - 본인 닉네임과 동일") + void isNicknameValid_fail_sameAsCurrentNickname() { + // given + String currentNickname = "current"; + String newNickname = "current"; + + // when & then + BusinessException exception = assertThrows(BusinessException.class, () -> memberService.isNicknameValid(newNickname, currentNickname)); + assertThat(exception.getResponseCode()).isEqualTo(SAME_AS_CURRENT_NICKNAME); + } + + /** + * [닉네임 유효성 검증] 실패 + * 2. 글자수가 안맞는 경우 (2글자 ~ 10글자) + */ + @ParameterizedTest + @ValueSource(strings = {"a", "verylongnickname123"}) + @DisplayName("닉네임 유효성 검증 실패 - 글자수가 안맞음") + void isNicknameValid_fail_inValidNicknameLength(String newNickname) { + BusinessException exception = assertThrows(BusinessException.class, () -> memberService.isNicknameValid(newNickname, "current")); + assertThat(exception.getResponseCode()).isEqualTo(INVALID_NICKNAME_LENGTH); + } + + /** + * [닉네임 유효성 검증] 실패 + * 3. 한글, 영문, 숫자 외의 문자가 존재하는 경우 + */ + @ParameterizedTest + @ValueSource(strings = {"hello@", "닉네임!", "test#123"}) + @DisplayName("닉네임 유효성 검증 실패 - 한글, 영문, 숫자 외의 문자 존재") + void isNicknameValid_fail_inValidNicknameFormat(String newNickname) { + BusinessException exception = assertThrows(BusinessException.class, () -> memberService.isNicknameValid(newNickname, "current")); + assertThat(exception.getResponseCode()).isEqualTo(INVALID_NICKNAME_FORMAT); + } + } + + @Nested + @DisplayName("닉네임 중복 여부 조회 테스트") + class CheckNicknameDuplicateTest { + private static final Long MEMBER_ID = 1L; + + // 헬퍼 메서드 + private Member createMockMember(Long memberId) { + Member member = Member.builder().nickname("current").build(); + ReflectionTestUtils.setField(member, "id", memberId); + return member; + } + + /** + * [닉네임 중복 여부 조회] 성공 + * 1. 중복되지 않은 닉네임인 경우 (true 응답) + */ + @Test + @DisplayName("닉네임 중복 확인 성공 - 중복되지 않은 닉네임") + void checkNicknameDuplicate_success_notDuplicateNickname() { + // given + Member member = createMockMember(MEMBER_ID); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + + String newNickname = "new"; + given(memberRepository.existsByNickname("new")).willReturn(false); + + // when + Boolean result = memberService.checkNicknameDuplicate(member.getId(), newNickname); + + // then + assertThat(result).isTrue(); + verify(memberRepository).findById(MEMBER_ID); + verify(memberRepository).existsByNickname(newNickname); + } + + /** + * [닉네임 중복 여부 조회] 성공 + * 2. 중복된 닉네임인 경우 (false 응답) + */ + @Test + @DisplayName("닉네임 중복 확인 성공 - 중복된 닉네임") + void checkNicknameDuplicate_success_duplicateNickname() { + // given + Member member = createMockMember(MEMBER_ID); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + + String newNickname = "duplicate"; + given(memberRepository.existsByNickname("duplicate")).willReturn(true); + + // when + Boolean result = memberService.checkNicknameDuplicate(member.getId(), newNickname); + + // then + assertThat(result).isFalse(); + verify(memberRepository).findById(MEMBER_ID); + verify(memberRepository).existsByNickname(newNickname); + } + + /** + * [닉네임 중복 여부 조회] 실패 + * 1. Member가 존재하지 않을 경우 + */ + @Test + @DisplayName("닉네임 중복 확인 실패 - 찾을 수 없는 사용자") + void checkNicknameDuplicate_fail_memberNotFound() { + // given + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.empty()); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> memberService.checkNicknameDuplicate(MEMBER_ID, anyString())); + assertThat(exception.getResponseCode()).isEqualTo(MEMBER_NOT_FOUND); + } + } + + @Nested + @DisplayName("내 정보 수정 테스트") + class UpdateMemberInfoTest { + private static final Long MEMBER_ID = 1L; + + // 헬퍼 메서드 + private Member createMockMember(Long memberId) { + Member member = Member.builder().nickname("current").build(); + ReflectionTestUtils.setField(member, "id", memberId); + return member; + } + + /** + * [내 정보 수정] 성공 + * 1. 수정을 성공한 경우 + */ + @Test + @DisplayName("내 정보 수정 성공") + void updateMemberInfo_success() { + // given + Member member = createMockMember(MEMBER_ID); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + + MemberUpdateRequestDto memberUpdateRequestDto = new MemberUpdateRequestDto("new"); + given(memberRepository.existsByNickname("new")).willReturn(false); + + // when + MemberUpdateResponseDto memberUpdateResponseDto = memberService.updateMemberInfo(member.getId(), memberUpdateRequestDto); + + // then + assertThat(memberUpdateResponseDto.nickname()).isEqualTo("new"); + verify(memberRepository).findById(MEMBER_ID); + verify(memberRepository).existsByNickname("new"); + } + + /** + * [내 정보 수정] 실패 + * 1. Member가 존재하지 않을 경우 + */ + @Test + @DisplayName("내 정보 수정 실패 - 찾을 수 없는 사용자") + void updateMemberInfo_fail_memberNotFound() { + // given + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.empty()); + MemberUpdateRequestDto memberUpdateRequestDto = new MemberUpdateRequestDto("new"); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> memberService.updateMemberInfo(MEMBER_ID, memberUpdateRequestDto)); + assertThat(exception.getResponseCode()).isEqualTo(MEMBER_NOT_FOUND); + } + + /** + * [내 정보 수정] 실패 + * 2. 중복된 닉네임인 경우 + */ + @Test + @DisplayName("내 정보 수정 - 중복된 닉네임") + void updateMemberInfo_fail_duplicateNickname() { + // given + Member member = createMockMember(MEMBER_ID); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + + MemberUpdateRequestDto memberUpdateRequestDto = new MemberUpdateRequestDto("duplicate"); + given(memberRepository.existsByNickname("duplicate")).willReturn(true); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> memberService.updateMemberInfo(MEMBER_ID, memberUpdateRequestDto)); + assertThat(exception.getResponseCode()).isEqualTo(DUPLICATE_NICKNAME); + } + } +} diff --git a/src/test/java/com/server/running_handai/domain/review/service/ReviewServiceTest.java b/src/test/java/com/server/running_handai/domain/review/service/ReviewServiceTest.java index 3dbb017..0a51bee 100644 --- a/src/test/java/com/server/running_handai/domain/review/service/ReviewServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/review/service/ReviewServiceTest.java @@ -8,12 +8,14 @@ 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.CourseImage; import com.server.running_handai.domain.course.entity.CourseLevel; import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.member.entity.Member; import com.server.running_handai.domain.member.entity.Provider; import com.server.running_handai.domain.member.entity.Role; import com.server.running_handai.domain.member.repository.MemberRepository; +import com.server.running_handai.domain.review.dto.MyReviewInfoDto; import com.server.running_handai.domain.review.dto.ReviewCreateResponseDto; import com.server.running_handai.domain.review.dto.ReviewInfoListDto; import com.server.running_handai.domain.review.dto.ReviewCreateRequestDto; @@ -28,6 +30,7 @@ import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; @@ -91,7 +94,7 @@ void setUp() throws ParseException { course = Course.builder() .name("courseName1") - .distance(10) + .distance(10.5) .duration(120) .level(CourseLevel.MEDIUM) .area(Area.HAEUN_GWANGAN) @@ -106,10 +109,14 @@ void setUp() throws ParseException { .contents(VALID_REVIEW_CONTENTS) .build(); + CourseImage courseImage = CourseImage.builder().imgUrl("img_url").build(); + // 테스트 리뷰에 연관관계 설정 review.setWriter(member); review.setCourse(course); + course.updateCourseImage(courseImage); + ReflectionTestUtils.setField(course, "id", 10L); ReflectionTestUtils.setField(review, "id", 100L); ReflectionTestUtils.setField(member, "id", 50L); ReflectionTestUtils.setField(review, "createdAt", LocalDateTime.now()); @@ -547,4 +554,53 @@ void deleteReview_fail_accessDenied() { } } + + @Nested + @DisplayName("내 리뷰 조회 테스트") + class GetMyReviewTest { + + @Test + @DisplayName("내 리뷰 조회 성공 - 리뷰가 있을 때") + void getMyReview_success_hasReview() { + // given + Long memberId = member.getId(); + List myReviews = List.of(review); + + given(reviewRepository.findReviewsWithDetailsByMemberId(memberId)).willReturn(myReviews); + + // when + List myReviewInfoDtos = reviewService.getMyReviews(memberId); + + // then + MyReviewInfoDto firstDto = myReviewInfoDtos.getFirst(); + assertThat(myReviewInfoDtos.size()).isEqualTo(1); + assertThat(firstDto.reviewId()).isEqualTo(review.getId()); + assertThat(firstDto.courseId()).isEqualTo(course.getId()); + assertThat(firstDto.courseName()).isEqualTo(course.getName()); + assertThat(firstDto.thumbnailUrl()).isEqualTo(course.getCourseImage().getImgUrl()); + assertThat(firstDto.area()).isEqualTo(course.getArea().name()); + assertThat(firstDto.distance()).isEqualTo((int) course.getDistance()); + assertThat(firstDto.duration()).isEqualTo(course.getDuration()); + assertThat(firstDto.maxElevation()).isEqualTo((int) course.getMaxElevation().doubleValue()); + + verify(reviewRepository).findReviewsWithDetailsByMemberId(memberId); + } + + @Test + @DisplayName("내 리뷰 조회 성공 - 리뷰가 없을 때") + void getMyReview_success_HasNoReview() { + // given + Long memberId = member.getId(); + + given(reviewRepository.findReviewsWithDetailsByMemberId(memberId)).willReturn(Collections.emptyList()); + + // when + List myReviewInfoDtos = reviewService.getMyReviews(memberId); + + // then + assertThat(myReviewInfoDtos).isEmpty(); + + verify(reviewRepository).findReviewsWithDetailsByMemberId(memberId); + } + } } \ No newline at end of file diff --git a/src/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java b/src/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java new file mode 100644 index 0000000..cfb516b --- /dev/null +++ b/src/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java @@ -0,0 +1,500 @@ +package com.server.running_handai.domain.spot.service; + +import com.server.running_handai.domain.course.service.FileService; +import com.server.running_handai.domain.spot.client.SpotApiClient; +import com.server.running_handai.domain.spot.client.SpotLocationApiClient; +import com.server.running_handai.domain.spot.dto.SpotApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotLocationApiResponseDto; +import com.server.running_handai.domain.course.entity.Course; +import com.server.running_handai.domain.spot.entity.Spot; +import com.server.running_handai.domain.course.entity.TrackPoint; +import com.server.running_handai.domain.course.repository.CourseRepository; +import com.server.running_handai.domain.spot.repository.CourseSpotRepository; +import com.server.running_handai.domain.spot.repository.SpotRepository; +import com.server.running_handai.domain.course.repository.TrackPointRepository; +import com.server.running_handai.global.response.ResponseCode; +import com.server.running_handai.global.response.exception.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.*; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) +class SpotDataServiceTest { + + @InjectMocks + private SpotDataService spotDataService; + + @Mock + private CourseRepository courseRepository; + + @Mock + private TrackPointRepository trackPointRepository; + + @Mock + private SpotRepository spotRepository; + + @Mock + private CourseSpotRepository courseSpotRepository; + + @Mock + private SpotLocationApiClient spotLocationApiClient; + + @Mock + private SpotApiClient spotApiClient; + + @Mock + private FileService fileService; + + private static final Long COURSE_ID = 1L; + private Course course; + private TrackPoint startPoint; + private TrackPoint endPoint; + + + @BeforeEach + void setUp() { + course = createMockCourse(COURSE_ID); + startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); + endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); + } + + /** + * [즐길거리 수정] 성공 + * 1. 모두 새로운 호출을 진행하는 경우 + */ + @Test + @DisplayName("즐길거리 수정 성공 - 모두 새로운 호출 진행") + void updateSpots_success_allNewFetch() { + // given + Set externalIds = Set.of("externalId1"); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + SpotLocationApiResponseDto spotLocationApiResponseDto = createSpotLocationApiResponse(externalIds); + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + SpotApiResponseDto spotApiResponseDto = createSpotApiResponse("externalId1"); + given(spotApiClient.fetchSpotData(anyString())).willReturn(spotApiResponseDto); + given(fileService.uploadFileByUrl(anyString(), eq("spot"))).willReturn("https://mock-s3-url.com/externalId1.png"); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + verify(courseRepository).findById(COURSE_ID); + verify(trackPointRepository).findByCourseIdOrderBySequenceAsc(course.getId()); + verify(spotLocationApiClient, times(4)).fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt()); + verify(spotRepository).findByExternalIdIn(anySet()); + verify(spotApiClient, times(externalIds.size())).fetchSpotData(anyString()); + verify(fileService, times(externalIds.size())).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(anyList()); + verify(courseSpotRepository).saveAll(anyList()); + } + + /** + * [즐길거리 수정] 성공 + * 2. SpotImage가 Null일 경우 + */ + @Test + @DisplayName("즐길거리 수정 성공 - SpotImage가 Null") + void updateSpots_success_noSpotImage() { + // given + Set externalIds = Set.of("externalId1"); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + SpotLocationApiResponseDto spotLocationApiResponseDto = createSpotLocationApiResponse(externalIds); + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + SpotApiResponseDto spotApiResponseDto = createSpotApiResponse("externalId1"); + SpotApiResponseDto.Item item = spotApiResponseDto.getResponse().getBody().getItems().getItemList().getFirst(); + // SpotImage을 생성할 수 있는 URL이 모두 Null이라 설정 + ReflectionTestUtils.setField(item, "spotOriginalImage", null); + ReflectionTestUtils.setField(item, "spotThumbnailImage", null); + given(spotApiClient.fetchSpotData(anyString())).willReturn(spotApiResponseDto); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // uploadFileByUrl이 아예 호출되지 않는지 확인 + verify(fileService, never()).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(anyList()); + verify(courseSpotRepository).saveAll(anyList()); + } + + /** + * [즐길거리 수정] 성공 + * 3. Spot 일부가 DB에 존재하는 경우 + */ + @Test + @DisplayName("즐길거리 수정 성공 - Spot 일부가 DB에 존재") + void updateSpots_success_existingSpots() { + // given + Set externalIds = Set.of("externalId1", "externalId2"); + Spot existingSpot1 = createMockSpot("externalId1"); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + SpotLocationApiResponseDto spotLocationApiResponseDto = createSpotLocationApiResponse(externalIds); + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto); + + // externalId1인 기존 Spot이 있다고 설정 + given(spotRepository.findByExternalIdIn(anySet())).willReturn(List.of(existingSpot1)); + SpotApiResponseDto spotApiResponseDto = createSpotApiResponse("externalId2"); + given(spotApiClient.fetchSpotData(anyString())).willReturn(spotApiResponseDto); + given(fileService.uploadFileByUrl(anyString(), eq("spot"))).willReturn("https://mock-s3-url.com/externalId2.png"); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // externalId2만 공통정보 조회 API 호출하고, externalId1은 호출하지 않는지 확인 + verify(spotApiClient, times(1)).fetchSpotData(eq("externalId2")); + verify(spotApiClient, never()).fetchSpotData(eq("externalId1")); + verify(fileService).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(anyList()); + verify(courseSpotRepository).saveAll(anyList()); + } + + /** + * [즐길거리 수정] 성공 + * 4. SpotApiResponseDto가 Null인 경우 + */ + @Test + @DisplayName("즐길거리 수정 성공 - SpotApiResponseDto가 Null") + void updateSpots_success_noSpotApiResponseDto() { + // given + Set externalIds = Set.of("externalId1"); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + SpotLocationApiResponseDto spotLocationApiResponseDto = createSpotLocationApiResponse(externalIds); + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + // 공통정보 조회 API 응답값이 Null이라고 설정 + given(spotApiClient.fetchSpotData(anyString())).willReturn(null); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // 공통정보 조회 API 호출은 되지만, 이미지 업로드는 실행되지 않고, 빈 리스트가 저장되는지 확인 + verify(spotApiClient).fetchSpotData(anyString()); + verify(fileService, never()).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + } + + /** + * [즐길거리 수정] 성공 + * 5. SpotLocationApiResponseDto가 Null인 경우 + */ + @Test + @DisplayName("즐길거리 수정 성공 - SpotLocationApiResponseDto가 Null") + void updateSpots_success_noSpotLocationApiResponseDto() { + // given + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + // 위치기반 정보조회 API 응답값이 Null이라고 설정 + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(null); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // 위치기반 정보조회 API 호출은 되지만, 공통정보 조회 API와 이미지 업로드는 실행되지 않고, 빈 리스트가 저장되는지 확인 + verify(spotLocationApiClient, times(4)).fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt()); + verify(spotApiClient, never()).fetchSpotData(anyString()); + verify(fileService, never()).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + } + + /** + * [즐길거리 수정] 성공 + * 6. 공통정보 조회 API 호출이 일부만 성공했을 경우 + */ + @Test + @DisplayName("즐길거리 수정 성공 - 공통정보 조회 API 일부만 성공") + void updateSpots_success_partialSpotApiFailure() { + // given + Set externalIds = Set.of("externalId1", "externalId2"); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + SpotLocationApiResponseDto spotLocationApiResponseDto = createSpotLocationApiResponse(externalIds); + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + // 공통정보 조회 API 응답값이 하나는 있고, 하나는 Null이라 가정 + SpotApiResponseDto spotApiResponseDto = createSpotApiResponse("externalId1"); + given(spotApiClient.fetchSpotData(eq("externalId1"))).willReturn(spotApiResponseDto); + given(spotApiClient.fetchSpotData(eq("externalId2"))).willReturn(null); + + Spot spot = createMockSpot("externalId1"); + given(spotRepository.saveAll(anyList())).willReturn(List.of(spot)); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // 공통정보 조회 API 호출은 각각 되지만, 이미지 업로드는 1번만 실행되고, 1개만 저장되는지 확인 + verify(spotApiClient, times(2)).fetchSpotData(anyString()); + verify(fileService, times(1)).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(argThat(list -> ((Collection) list).size() == 1)); + verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).size() == 1)); + } + + /** + * [즐길거리 수정] 성공 + * 7. 위치기반 정보조회 API 호출이 일부만 성공했을 경우 + */ + @Test + @DisplayName("즐길거리 수정 성공 - 위치기반 정보조회 API 일부만 성공") + void updateSpots_success_partialSpotLocationApiFailure() { + // given + Set externalIds = Set.of("externalId1"); // 성공해서 가져온 externalId가 1개라고 가정 + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + // 위치기반 정보조회 API 응답값이 하나는 있고, 하나는 Null이라 가정 + SpotLocationApiResponseDto spotLocationApiResponseDto = createSpotLocationApiResponse(externalIds); + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto) + .willReturn(null); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + + // 성공해서 가져온 externalId으로 공통정보 조회 API 호출 + SpotApiResponseDto spotApiResponseDto = createSpotApiResponse("externalId1"); + given(spotApiClient.fetchSpotData(eq("externalId1"))).willReturn(spotApiResponseDto); + + Spot spot = createMockSpot("externalId1"); + given(spotRepository.saveAll(anyList())).willReturn(List.of(spot)); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // 위치기반 정보조회 API 호출은 되지만, 공통정보 조회 API와 이미지 업로드는 1번씩만 실행되고, 1개만 저장되는지 확인 + verify(spotLocationApiClient, times(4)).fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt()); + verify(spotApiClient, times(1)).fetchSpotData(anyString()); + verify(fileService, times(1)).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(argThat(list -> ((Collection) list).size() == 1)); + verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).size() == 1)); + } + + /** + * [즐길거리 수정] 성공 + * 8. SpotApiResponseDto의 필드값이 유효하지 않은 값일 경우 + * - Null + * - 빈 문자열 + * - 위도, 경도의 경우 Double로 변환 불가 + */ + @Test + @DisplayName("즐길거리 수정 성공 - SpotApiResponseDto의 필드값이 유효하지 않음") + void updateSpots_success_invalidSpotApiResponseDtoField() { + // given + Set externalIds = Set.of("externalId1", "externalId2", "externalId3", "externalId4"); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + + SpotLocationApiResponseDto spotLocationApiResponseDto = createSpotLocationApiResponse(externalIds); + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + // 공통정보 조회 API 응답값의 spotName 필드가 Null이라고 설정 + SpotApiResponseDto spotApiResponseDto1 = createSpotApiResponse("externalId1"); + SpotApiResponseDto.Item item1 = spotApiResponseDto1.getResponse().getBody().getItems().getItemList().getFirst(); + ReflectionTestUtils.setField(item1, "spotName", null); + + // 공통정보 조회 API 응답값의 spotDescription 필드가 빈 문자열이라고 설정 + SpotApiResponseDto spotApiResponseDto2 = createSpotApiResponse("externalId2"); + SpotApiResponseDto.Item item2 = spotApiResponseDto2.getResponse().getBody().getItems().getItemList().getFirst(); + ReflectionTestUtils.setField(item2, "spotDescription", ""); + + // 공통정보 조회 API 응답값의 spotLongitude 필드가 Double이 아니라고 설정 + SpotApiResponseDto spotApiResponseDto3 = createSpotApiResponse("externalId3"); + SpotApiResponseDto.Item item3 = spotApiResponseDto3.getResponse().getBody().getItems().getItemList().getFirst(); + ReflectionTestUtils.setField(item3, "spotLongitude", "longitude"); + + // 공통정보 조회 API 응답값의 spotLatitude 필드가 Double이 아니라고 설정 + SpotApiResponseDto spotApiResponseDto4 = createSpotApiResponse("externalId4"); + SpotApiResponseDto.Item item4 = spotApiResponseDto4.getResponse().getBody().getItems().getItemList().getFirst(); + ReflectionTestUtils.setField(item4, "spotLatitude", "latitude"); + + given(spotApiClient.fetchSpotData(eq("externalId1"))).willReturn(spotApiResponseDto1); + given(spotApiClient.fetchSpotData(eq("externalId2"))).willReturn(spotApiResponseDto2); + given(spotApiClient.fetchSpotData(eq("externalId3"))).willReturn(spotApiResponseDto3); + given(spotApiClient.fetchSpotData(eq("externalId4"))).willReturn(spotApiResponseDto4); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // 공통정보 조회 API 호출은 되지만, 이미지 업로드는 실행되지 않고, 빈 리스트가 저장되는지 확인 + verify(spotApiClient, times(4)).fetchSpotData(anyString()); + verify(fileService, never()).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + } + + /** + * [즐길거리 수정] 성공 + * 9. SpotLocationApiResponseDto의 필드값이 유효하지 않은 값일 경우 + * - Null + * - 빈 문자열 + */ + @Test + @DisplayName("즐길거리 수정 성공 - SpotLocationApiResponseDto의 필드값이 유효하지 않음") + void updateSpots_success_noSpotLocationApiResponseDtoField() { + // given + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); + Set externalIds1 = Set.of("externalId1"); + Set externalIds2 = Set.of("externalId2"); + + // 위치기반 정보조회 API 응답값의 externalId 필드가 Null이라고 설정 + SpotLocationApiResponseDto spotLocationApiResponseDto1 = createSpotLocationApiResponse(externalIds1); + SpotLocationApiResponseDto.Item item1 = spotLocationApiResponseDto1.getResponse().getBody().getItems().getItemList().getFirst(); + ReflectionTestUtils.setField(item1, "spotExternalId", null); + + // 위치기반 정보조회 API 응답값의 externalId 필드가 Null이라고 설정 + SpotLocationApiResponseDto spotLocationApiResponseDto2 = createSpotLocationApiResponse(externalIds2); + SpotLocationApiResponseDto.Item item2 = spotLocationApiResponseDto2.getResponse().getBody().getItems().getItemList().getFirst(); + ReflectionTestUtils.setField(item2, "spotExternalId", ""); + + given(spotLocationApiClient.fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt())) + .willReturn(spotLocationApiResponseDto1, spotLocationApiResponseDto2); + + given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); + + // when + spotDataService.updateSpots(COURSE_ID); + + // then + // 위치기반 정보조회 API 호출은 되지만, 공통정보 조회 API와 이미지 업로드는 실행되지 않고, 빈 리스트가 저장되는지 확인 + verify(spotLocationApiClient, times(4)).fetchSpotLocationData(anyInt(), anyInt(), anyString(), anyDouble(), anyDouble(), anyInt()); + verify(spotApiClient, never()).fetchSpotData(anyString()); + verify(fileService, never()).uploadFileByUrl(anyString(), eq("spot")); + verify(courseSpotRepository).deleteByCourseId(COURSE_ID); + verify(spotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); + } + + /** + * [즐길거리 수정] 실패 + * 1. Course가 없는 경우 + */ + @Test + @DisplayName("즐길거리 수정 실패 - Course가 없음") + void updateSpots_fail_courseNotFound() { + // given + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.empty()); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> spotDataService.updateSpots(COURSE_ID)); + assertThat(exception.getResponseCode()).isEqualTo(ResponseCode.COURSE_NOT_FOUND); + } + + // 헬퍼 메서드 + private Course createMockCourse(Long courseId) { + Course course = Course.builder().build(); + ReflectionTestUtils.setField(course, "id", courseId); + return course; + } + + private Spot createMockSpot(String externalId) { + return Spot.builder().externalId(externalId).build(); + } + + private SpotLocationApiResponseDto createSpotLocationApiResponse(Set externalIds) { + SpotLocationApiResponseDto dto = new SpotLocationApiResponseDto(); + SpotLocationApiResponseDto.Response response = new SpotLocationApiResponseDto.Response(); + SpotLocationApiResponseDto.Body body = new SpotLocationApiResponseDto.Body(); + SpotLocationApiResponseDto.Items items = new SpotLocationApiResponseDto.Items(); + + List itemList = new ArrayList<>(); + for (String externalId : externalIds) { + SpotLocationApiResponseDto.Item item = new SpotLocationApiResponseDto.Item(); + ReflectionTestUtils.setField(item, "spotExternalId", externalId); + itemList.add(item); + } + + ReflectionTestUtils.setField(items, "itemList", itemList); + ReflectionTestUtils.setField(body, "items", items); + ReflectionTestUtils.setField(response, "body", body); + ReflectionTestUtils.setField(dto, "response", response); + + return dto; + } + + private SpotApiResponseDto createSpotApiResponse(String externalId) { + SpotApiResponseDto dto = new SpotApiResponseDto(); + SpotApiResponseDto.Response response = new SpotApiResponseDto.Response(); + SpotApiResponseDto.Body body = new SpotApiResponseDto.Body(); + SpotApiResponseDto.Items items = new SpotApiResponseDto.Items(); + SpotApiResponseDto.Item item = new SpotApiResponseDto.Item(); + + ReflectionTestUtils.setField(item, "spotExternalId", externalId); + ReflectionTestUtils.setField(item, "spotName", "Test Spot"); + ReflectionTestUtils.setField(item, "spotAddress", "Test Address"); + ReflectionTestUtils.setField(item, "spotDescription", "Test Description"); + ReflectionTestUtils.setField(item, "spotCategoryNumber", "12"); + ReflectionTestUtils.setField(item, "spotLatitude", "37.123"); + ReflectionTestUtils.setField(item, "spotLongitude", "127.123"); + ReflectionTestUtils.setField(item, "spotOriginalImage", "http://example.com/original.png"); + ReflectionTestUtils.setField(item, "spotThumbnailImage", "http://example.com/thumbnail.png"); + + ReflectionTestUtils.setField(items, "itemList", List.of(item)); + ReflectionTestUtils.setField(body, "items", items); + ReflectionTestUtils.setField(response, "body", body); + ReflectionTestUtils.setField(dto, "response", response); + + return dto; + } +} \ No newline at end of file diff --git a/src/test/java/com/server/running_handai/domain/spot/service/SpotServiceTest.java b/src/test/java/com/server/running_handai/domain/spot/service/SpotServiceTest.java new file mode 100644 index 0000000..bfdea37 --- /dev/null +++ b/src/test/java/com/server/running_handai/domain/spot/service/SpotServiceTest.java @@ -0,0 +1,140 @@ +package com.server.running_handai.domain.spot.service; + +import com.server.running_handai.domain.course.entity.Course; +import com.server.running_handai.domain.course.repository.CourseRepository; +import com.server.running_handai.domain.spot.dto.SpotDetailDto; +import com.server.running_handai.domain.spot.dto.SpotInfoDto; +import com.server.running_handai.domain.spot.entity.Spot; +import com.server.running_handai.domain.spot.entity.SpotImage; +import com.server.running_handai.domain.spot.repository.SpotRepository; +import com.server.running_handai.global.response.exception.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static com.server.running_handai.global.response.ResponseCode.COURSE_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; + +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) +class SpotServiceTest { + @InjectMocks + private SpotService spotService; + + @Mock + private CourseRepository courseRepository; + + @Mock + private SpotRepository spotRepository; + + private static final Long COURSE_ID = 1L; + + @Nested + @DisplayName("즐길거리 전체 조회 테스트") + class GetAllSpotTest { + private SpotImage createMockSpotImage(String imageUrl) { + return SpotImage.builder().imgUrl(imageUrl).build(); + } + + private Spot createMockSpot(Long spotId, String name, String description, SpotImage spotImage) { + Spot spot = Spot.builder().name(name).description(description).build(); + ReflectionTestUtils.setField(spot, "id", spotId); + ReflectionTestUtils.setField(spot, "spotImage", spotImage); + return spot; + } + + /** + * [즐길거리 전체 조회] 성공 + * 1. Course에 해당되는 Spot이 존재하는 경우 + */ + @Test + @DisplayName("즐길거리 전체 조회 - Spot이 존재") + void getSpotDetails_success_spotExists() { + // given + // 2개의 Spot 중 1개만 SpotImage가 있다고 가정 + SpotImage spotImage = createMockSpotImage("http://mock-image-url"); + Spot spot1 = createMockSpot(101L, "Spot1", "Description1", spotImage); + Spot spot2 = createMockSpot(102L, "Spot2", "Description2", null); + List spots = List.of(spot1, spot2); + + given(courseRepository.existsById(COURSE_ID)).willReturn(true); + given(spotRepository.findByCourseIdWithSpotImage(COURSE_ID)).willReturn(spots); + + // when + SpotDetailDto result = spotService.getSpotDetails(COURSE_ID); + + // then + assertThat(result.courseId()).isEqualTo(COURSE_ID); + assertThat(result.spotCount()).isEqualTo(spots.size()); + assertThat(result.spots()).hasSize(2); + + SpotInfoDto spotInfo1 = result.spots().get(0); + assertThat(spotInfo1.spotId()).isEqualTo(spot1.getId()); + assertThat(spotInfo1.name()).isEqualTo(spot1.getName()); + assertThat(spotInfo1.description()).isEqualTo(spot1.getDescription()); + assertThat(spotInfo1.imageUrl()).isEqualTo("http://mock-image-url"); + + SpotInfoDto spotInfo2 = result.spots().get(1); + assertThat(spotInfo2.spotId()).isEqualTo(spot2.getId()); + assertThat(spotInfo2.name()).isEqualTo(spot2.getName()); + assertThat(spotInfo2.description()).isEqualTo(spot2.getDescription()); + assertThat(spotInfo2.imageUrl()).isNull(); + + verify(courseRepository).existsById(COURSE_ID); + verify(spotRepository).findByCourseIdWithSpotImage(COURSE_ID); + } + + /** + * [즐길거리 전체 조회] 성공 + * 2. Course에 해당되는 Spot이 존재하지 않는 경우 + */ + @Test + @DisplayName("즐길거리 전체 조회 - Spot이 존재하지 않음") + void getSpotDetails_success_noSpot() { + // given + // Spot이 존재하지 않으면 빈 리스트로 응답해야 함 + given(courseRepository.existsById(COURSE_ID)).willReturn(true); + given(spotRepository.findByCourseIdWithSpotImage(COURSE_ID)).willReturn(Collections.emptyList()); + + // when + SpotDetailDto result = spotService.getSpotDetails(COURSE_ID); + + // then + assertThat(result.courseId()).isEqualTo(COURSE_ID); + assertThat(result.spotCount()).isEqualTo(0); + assertThat(result.spots()).isEmpty(); + + verify(courseRepository).existsById(COURSE_ID); + verify(spotRepository).findByCourseIdWithSpotImage(COURSE_ID); + } + + /** + * [즐길거리 전체 조회] 실패 + * 1. Course가 존재하지 않을 경우 + */ + @Test + @DisplayName("즐길거리 전체 조회 - 존재하지 않는 코스") + void getSpotDetails_fail_courseNotFound() { + // given + given(courseRepository.existsById(COURSE_ID)).willReturn(false); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> spotService.getSpotDetails(COURSE_ID)); + assertThat(exception.getResponseCode()).isEqualTo(COURSE_NOT_FOUND); + } + } +}