From 3c15f371b8328e09aa7da92369cca2a70164489a Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 31 Jul 2025 20:45:31 +0900 Subject: [PATCH 01/45] =?UTF-8?q?[SCRUM-212]=20FEAT:=20Spot=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20Entity=20=EC=83=9D=EC=84=B1=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spot, SpotImage 엔티티를 생성하고, Category 필드를 Enum 타입으로 추가하였습니다. 또한 Course와 Spot 간의 다대다 관계를 표현하기 위해 중간 테이블 역할을 하는 CourseSpot 엔티티도 함께 생성하였습니다. --- .../domain/course/entity/Category.java | 41 +++++++++++++++ .../domain/course/entity/Course.java | 4 ++ .../domain/course/entity/CourseSpot.java | 27 ++++++++++ .../domain/course/entity/Spot.java | 52 +++++++++++++++++++ .../domain/course/entity/SpotImage.java | 26 ++++++++++ 5 files changed, 150 insertions(+) create mode 100644 src/main/java/com/server/running_handai/domain/course/entity/Category.java create mode 100644 src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java create mode 100644 src/main/java/com/server/running_handai/domain/course/entity/Spot.java create mode 100644 src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Category.java b/src/main/java/com/server/running_handai/domain/course/entity/Category.java new file mode 100644 index 0000000..fcf30e0 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/entity/Category.java @@ -0,0 +1,41 @@ +package com.server.running_handai.domain.course.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Getter +@RequiredArgsConstructor +public enum Category { + NATURE("자연관광지", List.of("A01010100", "A01010200", "A01010300", "A01010400", "A01010500", "A01010600", "A01010700", "A01010800", "A01010900", "A01011000", "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")); + + 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(Category.values()) + .filter(category -> category.getCategoryNumber().contains(categoryNumber)) + .findFirst(); + } +} 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 6deac9a..d05339f 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 @@ -87,6 +87,10 @@ public class Course extends BaseTimeEntity { @OneToOne(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) private CourseImage courseImage; // 썸네일 이미지 + // CourseSpot과 일대다 관계 + @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) + private List courseSpots = new ArrayList<>(); + @Builder public Course(String externalId, String name, int distance, int duration, CourseLevel level, String tourPoint, Area area, String gpxPath, diff --git a/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java b/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java new file mode 100644 index 0000000..0daaa34 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java @@ -0,0 +1,27 @@ +package com.server.running_handai.domain.course.entity; + +import com.server.running_handai.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "course_spot") +@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; +} diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Spot.java b/src/main/java/com/server/running_handai/domain/course/entity/Spot.java new file mode 100644 index 0000000..bcdb40d --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/entity/Spot.java @@ -0,0 +1,52 @@ +package com.server.running_handai.domain.course.entity; + +import com.server.running_handai.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +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") + private String description; // 설명 + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false) + private Category category; // 카테고리 + + @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; +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java b/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java new file mode 100644 index 0000000..a25c582 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java @@ -0,0 +1,26 @@ +package com.server.running_handai.domain.course.entity; + +import com.server.running_handai.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +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 + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spot_id", unique = true, nullable = false) + private Spot spot; +} From 446116de6fa60f1cc26082d7c4a521f5850ae0a4 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 31 Jul 2025 20:51:34 +0900 Subject: [PATCH 02/45] =?UTF-8?q?[SCRUM-212]=20FEAT:=20=EA=B5=AD=EB=AC=B8?= =?UTF-8?q?=20=EA=B4=80=EA=B4=91=EC=A0=95=EB=B3=B4=20API=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebClient를 활용해 [국문 관광정보] 위치기반 관광정보 API와 공통정보 조회 API를 호출하고, 응답을 DTO로 파싱하는 코드를 구현했습니다. --- .env.example | 3 + docker-compose.yml | 1 + .../domain/course/client/SpotApiClient.java | 54 +++++++++ .../course/client/SpotLocationApiClient.java | 74 +++++++++++++ .../domain/course/dto/SpotApiResponseDto.java | 103 ++++++++++++++++++ .../dto/SpotLocationApiResponseDto.java | 80 ++++++++++++++ .../global/config/WebClientConfig.java | 1 - src/main/resources/application-test.yml | 8 +- src/main/resources/application.yml | 8 ++ 9 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java create mode 100644 src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java create mode 100644 src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java create mode 100644 src/main/java/com/server/running_handai/domain/course/dto/SpotLocationApiResponseDto.java 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/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java b/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java new file mode 100644 index 0000000..a8c05dc --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java @@ -0,0 +1,54 @@ +package com.server.running_handai.domain.course.client; + +import com.server.running_handai.domain.course.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; + + @Value("${external.api.spot.radius}") + private String radius; + + /** + * [국문 관광정보] 공통정보 조회 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(); + log.info(String.valueOf(uri)); + + // 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/course/client/SpotLocationApiClient.java b/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java new file mode 100644 index 0000000..67cad0f --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java @@ -0,0 +1,74 @@ +package com.server.running_handai.domain.course.client; + +import com.server.running_handai.domain.course.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 radius 거리 반경 (50000m = 5km) + * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) + * @return SpotLocationResponseDto + */ + public SpotLocationApiResponseDto fetchSpotLocationData( + int pageNo, + int numOfRows, + String arrange, + double lon, + double lat, + int radius, + 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", String.valueOf(radius)) + .queryParam("contentTypeId", String.valueOf(contentTypeId)) + .queryParam("serviceKey", serviceKey); + + URI uri = builder.build(true).toUri(); + log.info(String.valueOf(uri)); + + // 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/course/dto/SpotApiResponseDto.java b/src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java new file mode 100644 index 0000000..7f84ff6 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java @@ -0,0 +1,103 @@ +package com.server.running_handai.domain.course.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 spotIndex; // 장소 고유번호 (Spot.externalId) + + @JsonProperty("title") + private String spotName; // 장소 이름 (Spot.name) + + @JsonProperty("overview") + private String description; // 장소 설명 (Spot.description) + + @JsonProperty("addr1") + private String spotAddress; // 장소 주소 (Spot.address) + + @JsonProperty("cat3") + private String category; // 장소 카테고리 (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/course/dto/SpotLocationApiResponseDto.java b/src/main/java/com/server/running_handai/domain/course/dto/SpotLocationApiResponseDto.java new file mode 100644 index 0000000..548c6f9 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/SpotLocationApiResponseDto.java @@ -0,0 +1,80 @@ +package com.server.running_handai.domain.course.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/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/resources/application-test.yml b/src/main/resources/application-test.yml index 8189988..50fbe6c 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -71,6 +71,8 @@ external: api: durunubi: service-key: "test-service-key" + spot: + service-key: "test-spot-service-key" jwt: secret-key: "test-jwt-secret-key" @@ -88,4 +90,8 @@ cors: course: simplification: - distance-tolerance: 0.0001 \ No newline at end of file + distance-tolerance: 0.0001 + +spot: + search: + radius: 50000 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c8524e1..d3d8a6e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -90,6 +90,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: 5000 springdoc: default-produces-media-type: application/json @@ -116,3 +120,7 @@ logging: course: simplification: distance-tolerance: 0.0001 # 경로 단순화 허용 오차 (RDP 알고리즘), 약 10m + +spot: + search: + radius: 50000 # [국문 관광정보] 위치기반 관광정보 조회 API 거리 반경 \ No newline at end of file From c323027d21198bf5e86c98be5a5871e7292d3b8b Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 31 Jul 2025 21:00:38 +0900 Subject: [PATCH 03/45] =?UTF-8?q?[SCRUM-212]=20FEAT:=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B4=80=EA=B4=91=EC=A0=95=EB=B3=B4=20API?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20externalId=20=EC=88=98=EC=A7=91=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출발점과 종료점에서 관광지(12)와 음식점(39) 데이터를 [국문 관광정보] 위치기반 관광정보 API로 조회하는 기능을 추가했습니다. 각 좌표와 카테고리별로 최대 5개씩 데이터를 호출하며, 중복된 externalId는 Set으로 자동 제거됩니다. --- .../course/service/CourseDataService.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) 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 02eb9ac..d8ddad6 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 @@ -7,6 +7,7 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.fasterxml.jackson.dataformat.xml.deser.FromXmlParser; import com.server.running_handai.domain.course.client.DurunubiApiClient; +import com.server.running_handai.domain.course.client.SpotLocationApiClient; import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.course.dto.DurunubiApiResponseDto.Item; import com.server.running_handai.domain.course.entity.Area; @@ -55,6 +56,7 @@ public class CourseDataService { private final ObjectMapper objectMapper; private final DurunubiApiClient durunubiApiClient; + private final SpotLocationApiClient spotLocationApiClient; private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; private final RoadConditionRepository roadConditionRepository; @@ -74,6 +76,9 @@ public class CourseDataService { @Value("${course.simplification.distance-tolerance}") private double distanceTolerance; + @Value("${spot.search.radius}") + private int radius; + /** * 두루누비 API 관련 */ @@ -445,6 +450,32 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) thro } } + /** + * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 API와 공통정보조회 API를 통해 가져옵니다. + * + * @param courseId 코스 id + */ + @Transactional + public void createSpots(Long courseId) { + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); + + List trackPoints = trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId()); + TrackPoint startPoint = trackPoints.getFirst(); + TrackPoint endPoint = trackPoints.getLast(); + Set externalIds = new HashSet<>(); + + // contentTypeId: 12 (관광지) + externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 12)); + externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 12)); + + // contentTypeId: 39 (음식점) + externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 39)); + externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 39)); + + log.info("[즐길거리 생성] 수집된 고유 externalId 개수: {}", externalIds.size()); + log.info("[즐길거리 생성] externalIds: {}", externalIds); + } + /** * GPX 파일을 받아 코스 정보를 생성하고 저장합니다. * OpenAI API의 경우, 예상 토큰 값을 계산하여 최대 토큰 값을 넘으면 RDP 단순화 알고리즘을 적용하여 요청합니다. @@ -804,4 +835,31 @@ private String convertTrackPointToJson(List trackPointDto return trackPointDtoList.toString(); } } + + /** + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. + * + * @param lon 경도 (x) + * @param lat 위도 (y) + * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) + * @return externalId의 Set + */ + private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { + SpotLocationApiResponseDto response = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, radius, contentTypeId); + + if (response.getResponse() == null || response.getResponse().getBody() == null || response.getResponse().getBody().getItems() == null) { + return Collections.emptySet(); + } + + List items = response.getResponse().getBody().getItems().getItemList(); + + if (items == null) { + return Collections.emptySet(); + } + + return items.stream() + .map(SpotLocationApiResponseDto.Item::getSpotExternalId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } } From befd96714bac2b38c8e2368816ea8bddc11641a6 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Fri, 1 Aug 2025 08:28:38 +0900 Subject: [PATCH 04/45] =?UTF-8?q?[SCRUM-212]=20FEAT:=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90=EC=84=9C?= =?UTF-8?q?=20Spot=20=EA=B4=80=EB=A0=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20DB?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수집된 externalId를 통해 [국문 관광정보] 공통정보 조회 API를 요청하여 Spot 관련 데이터를 DB에 저장하는 기능을 추가했습니다. 기존에 저장된 Spot이 있는 경우, 해당 Spot을 가져옵니다. --- .../domain/course/client/SpotApiClient.java | 4 - .../course/client/SpotLocationApiClient.java | 5 +- .../controller/CourseDataController.java | 6 + .../domain/course/dto/SpotApiResponseDto.java | 6 +- .../domain/course/entity/Category.java | 6 +- .../domain/course/entity/CourseSpot.java | 14 ++- .../domain/course/entity/Spot.java | 15 ++- .../domain/course/entity/SpotImage.java | 3 + .../repository/CourseSpotRepository.java | 13 +++ .../course/repository/SpotRepository.java | 13 +++ .../course/service/CourseDataService.java | 103 ++++++++++++++---- src/main/resources/application-test.yml | 9 +- src/main/resources/application.yml | 8 +- 13 files changed, 156 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/course/repository/CourseSpotRepository.java create mode 100644 src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java diff --git a/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java b/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java index a8c05dc..62376fd 100644 --- a/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java +++ b/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java @@ -22,9 +22,6 @@ public class SpotApiClient { @Value("${external.api.spot.service-key}") private String serviceKey; - @Value("${external.api.spot.radius}") - private String radius; - /** * [국문 관광정보] 공통정보 조회 API를 요청합니다. * @@ -42,7 +39,6 @@ public SpotApiResponseDto fetchSpotData(String contentId) { .queryParam("serviceKey", serviceKey); URI uri = builder.build(true).toUri(); - log.info(String.valueOf(uri)); // API 호출 return webClient.get() diff --git a/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java b/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java index 67cad0f..e6e3367 100644 --- a/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java +++ b/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java @@ -33,7 +33,6 @@ public class SpotLocationApiClient { * @param arrange 정렬 구분 (E: 거리순, S: 대표 이미지가 반드시 있는 거리순) * @param lon 경도 (x) * @param lat 위도 (y) - * @param radius 거리 반경 (50000m = 5km) * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) * @return SpotLocationResponseDto */ @@ -43,7 +42,6 @@ public SpotLocationApiResponseDto fetchSpotLocationData( String arrange, double lon, double lat, - int radius, int contentTypeId ) { // URL 생성 @@ -57,12 +55,11 @@ public SpotLocationApiResponseDto fetchSpotLocationData( .queryParam("arrange", arrange) .queryParam("mapX", String.valueOf(lon)) .queryParam("mapY", String.valueOf(lat)) - .queryParam("radius", String.valueOf(radius)) + .queryParam("radius", radius) .queryParam("contentTypeId", String.valueOf(contentTypeId)) .queryParam("serviceKey", serviceKey); URI uri = builder.build(true).toUri(); - log.info(String.valueOf(uri)); // API 호출 return webClient.get() 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..499148b 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 @@ -38,6 +38,12 @@ public ResponseEntity> updateCourseImage(@PathVariable Long co return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); } + @PutMapping("/{courseId}/spots") + public ResponseEntity> updateSpots(@PathVariable Long courseId) { + courseDataService.updateSpots(courseId); + 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 { diff --git a/src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java b/src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java index 7f84ff6..a009f13 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java +++ b/src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java @@ -74,19 +74,19 @@ public static class Items { @JsonIgnoreProperties(ignoreUnknown = true) public static class Item { @JsonProperty("contentid") - private String spotIndex; // 장소 고유번호 (Spot.externalId) + private String spotExternalId; // 장소 고유번호 (Spot.externalId) @JsonProperty("title") private String spotName; // 장소 이름 (Spot.name) @JsonProperty("overview") - private String description; // 장소 설명 (Spot.description) + private String spotDescription; // 장소 설명 (Spot.description) @JsonProperty("addr1") private String spotAddress; // 장소 주소 (Spot.address) @JsonProperty("cat3") - private String category; // 장소 카테고리 (Spot.category) + private String spotCategoryNumber; // 장소 카테고리 (Spot.category) @JsonProperty("firstimage") private String spotOriginalImage; // 대표 이미지 - 원본 (SpotImage.imageUrl) diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Category.java b/src/main/java/com/server/running_handai/domain/course/entity/Category.java index fcf30e0..1538c83 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Category.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Category.java @@ -10,7 +10,8 @@ @Getter @RequiredArgsConstructor public enum Category { - NATURE("자연관광지", List.of("A01010100", "A01010200", "A01010300", "A01010400", "A01010500", "A01010600", "A01010700", "A01010800", "A01010900", "A01011000", "A01020100", "A01020200")), + 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")), @@ -22,7 +23,8 @@ public enum Category { CHINESE_FOOD("중식", List.of("A05020400")), GLOBAL_FOOD("이색음식점", List.of("A05020700")), CAFE("카페", List.of("A05020900")), - CLUB("클럽", List.of("A05021000")); + CLUB("클럽", List.of("A05021000")), + UNKNOWN("알수없음", List.of()); private final String description; private final List categoryNumber; diff --git a/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java b/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java index 0daaa34..f69bca2 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java @@ -3,12 +3,18 @@ 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") +@Table(name = "course_spot", uniqueConstraints = { + @UniqueConstraint( + name = "course_spot_uk", + columnNames = {"course_id", "spot_id"} + ) +}) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CourseSpot extends BaseTimeEntity { @@ -24,4 +30,10 @@ public class CourseSpot extends BaseTimeEntity { @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/course/entity/Spot.java b/src/main/java/com/server/running_handai/domain/course/entity/Spot.java index bcdb40d..3b06ff2 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Spot.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Spot.java @@ -3,6 +3,7 @@ import com.server.running_handai.global.entity.BaseTimeEntity; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -29,7 +30,7 @@ public class Spot extends BaseTimeEntity { @Column(name = "address", nullable = false) private String address; // 주소 - @Column(name = "description") + @Column(name = "description", columnDefinition = "TEXT") private String description; // 설명 @Enumerated(EnumType.STRING) @@ -49,4 +50,16 @@ public class Spot extends BaseTimeEntity { // SpotImage와 일대일 관계 @OneToOne(mappedBy = "spot", cascade = CascadeType.ALL, orphanRemoval = true) private SpotImage spotImage; + + @Builder + public Spot(String externalId, String name, String address, String description, + Category category, double lat, double lon) { + this.externalId = externalId; + this.name = name; + this.address = address; + this.description = description; + this.category = category; + this.lat = lat; + this.lon = lon; + } } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java b/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java index a25c582..006345d 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java @@ -20,6 +20,9 @@ public class SpotImage extends BaseTimeEntity { @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; diff --git a/src/main/java/com/server/running_handai/domain/course/repository/CourseSpotRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/CourseSpotRepository.java new file mode 100644 index 0000000..b7eb552 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/repository/CourseSpotRepository.java @@ -0,0 +1,13 @@ +package com.server.running_handai.domain.course.repository; + +import com.server.running_handai.domain.course.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/course/repository/SpotRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java new file mode 100644 index 0000000..a28dd45 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java @@ -0,0 +1,13 @@ +package com.server.running_handai.domain.course.repository; + +import com.server.running_handai.domain.course.entity.Spot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface SpotRepository extends JpaRepository { + Optional findByExternalId(String externalId); + List findByExternalIdIn(Collection externalIds); +} \ No newline at end of file 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 d8ddad6..c0bde47 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 @@ -7,19 +7,12 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.fasterxml.jackson.dataformat.xml.deser.FromXmlParser; import com.server.running_handai.domain.course.client.DurunubiApiClient; +import com.server.running_handai.domain.course.client.SpotApiClient; import com.server.running_handai.domain.course.client.SpotLocationApiClient; import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.course.dto.DurunubiApiResponseDto.Item; -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.entity.RoadCondition; -import com.server.running_handai.domain.course.entity.Theme; -import com.server.running_handai.domain.course.entity.TrackPoint; -import com.server.running_handai.domain.course.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.entity.*; +import com.server.running_handai.domain.course.repository.*; import com.server.running_handai.domain.course.util.TrackPointSimplificationUtil; import com.server.running_handai.global.response.exception.BusinessException; @@ -57,9 +50,12 @@ public class CourseDataService { private final DurunubiApiClient durunubiApiClient; private final SpotLocationApiClient spotLocationApiClient; + private final SpotApiClient spotApiClient; private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; private final RoadConditionRepository roadConditionRepository; + private final CourseSpotRepository courseSpotRepository; + private final SpotRepository spotRepository; private final KakaoMapService kakaoMapService; private final OpenAiService openAiService; private final FileService fileService; @@ -76,9 +72,6 @@ public class CourseDataService { @Value("${course.simplification.distance-tolerance}") private double distanceTolerance; - @Value("${spot.search.radius}") - private int radius; - /** * 두루누비 API 관련 */ @@ -456,24 +449,64 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) thro * @param courseId 코스 id */ @Transactional - public void createSpots(Long courseId) { + public void updateSpots(Long courseId) { Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); List trackPoints = trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId()); TrackPoint startPoint = trackPoints.getFirst(); TrackPoint endPoint = trackPoints.getLast(); + + // 1. 장소 externalId 수집 + // 조회 조건: 시작점, 출발점, 관광지(12), 음식점(39) Set externalIds = new HashSet<>(); - // contentTypeId: 12 (관광지) externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 12)); externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 12)); - - // contentTypeId: 39 (음식점) externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 39)); externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 39)); - log.info("[즐길거리 생성] 수집된 고유 externalId 개수: {}", externalIds.size()); - log.info("[즐길거리 생성] externalIds: {}", externalIds); + 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); + + for (String externalId : externalIds) { + fetchSpot(externalId).ifPresent(item -> { + Spot spot = Spot.builder() + .externalId(item.getSpotExternalId()) + .name(item.getSpotName()) + .address(item.getSpotAddress()) + .description(item.getSpotDescription()) + .category(Category.findByCategoryNumber(item.getSpotCategoryNumber()) + .orElse(Category.UNKNOWN)) + .lat(Double.parseDouble(item.getSpotLatitude())) + .lon(Double.parseDouble(item.getSpotLongitude())) + .build(); + + spots.add(spot); + }); + } + + // 3. 기존 데이터 일괄 삭제 + courseSpotRepository.deleteByCourseId(courseId); + log.info("[즐길거리 수정] 기존 즐길거리 데이터 삭제 완료: courseId={}", courseId); + + // 4. Spot, CourseSpot DB 저장 + List newSpots = spotRepository.saveAll(spots); + + List courseSpots = newSpots.stream() + .map(spot -> new CourseSpot(course, spot)) + .collect(Collectors.toList()); + + courseSpotRepository.saveAll(courseSpots); + log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 개수={}", courseId, spots.size()); } /** @@ -845,15 +878,16 @@ private String convertTrackPointToJson(List trackPointDto * @return externalId의 Set */ private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { - SpotLocationApiResponseDto response = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, radius, contentTypeId); + SpotLocationApiResponseDto spotLocationApiResponseDto = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, contentTypeId); - if (response.getResponse() == null || response.getResponse().getBody() == null || response.getResponse().getBody().getItems() == null) { + if (spotLocationApiResponseDto == null || spotLocationApiResponseDto.getResponse() == null || + spotLocationApiResponseDto.getResponse().getBody() == null || spotLocationApiResponseDto.getResponse().getBody().getItems() == null) { return Collections.emptySet(); } - List items = response.getResponse().getBody().getItems().getItemList(); + List items = spotLocationApiResponseDto.getResponse().getBody().getItems().getItemList(); - if (items == null) { + if (items == null || items.isEmpty()) { return Collections.emptySet(); } @@ -862,4 +896,27 @@ private Set fetchSpotsByLocation(double lon, double lat, int contentType .filter(Objects::nonNull) .collect(Collectors.toSet()); } + + /** + * 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()); + } } diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 50fbe6c..2eddf2b 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -70,9 +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" @@ -90,8 +93,4 @@ cors: course: simplification: - distance-tolerance: 0.0001 - -spot: - search: - radius: 50000 \ No newline at end of file + distance-tolerance: 0.0001 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d3d8a6e..ad5d371 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -93,7 +93,7 @@ external: spot: base-url: http://apis.data.go.kr/B551011/KorService2 service-key: ${SPOT_SERVICE_KEY} - radius: 5000 + radius: 50000 # [국문 관광정보] 위치기반 관광정보 조회 API 거리 반경 (50000m = 5km) springdoc: default-produces-media-type: application/json @@ -119,8 +119,4 @@ logging: course: simplification: - distance-tolerance: 0.0001 # 경로 단순화 허용 오차 (RDP 알고리즘), 약 10m - -spot: - search: - radius: 50000 # [국문 관광정보] 위치기반 관광정보 조회 API 거리 반경 \ No newline at end of file + distance-tolerance: 0.0001 # 경로 단순화 허용 오차 (RDP 알고리즘), 약 10m \ No newline at end of file From 624da3fc4029eb3fe24cbfe742ac8d38c906c625 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Fri, 1 Aug 2025 09:05:13 +0900 Subject: [PATCH 05/45] =?UTF-8?q?[SCRUM-212]=20FEAT:=20Spot=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?DDL=20=EC=B6=94=EA=B0=80=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spot, SpotImage, CourseImage Entity에 대한 데이터베이스 테이블을 생성하는 DDL을 추가했습니다. --- sql/V1_create_course_related_tables.sql | 41 ++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/sql/V1_create_course_related_tables.sql b/sql/V1_create_course_related_tables.sql index a7e9cad..1afd2d9 100644 --- a/sql/V1_create_course_related_tables.sql +++ b/sql/V1_create_course_related_tables.sql @@ -113,4 +113,43 @@ create table track_point updated_at datetime(6) not null, constraint FKpupqiw5q83q159swqraqw9hpm foreign key (course_id) references course (course_id) -); \ No newline at end of file +); + +-- 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. CourseSpot 테이블 생성 +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. CourseImage 테이블 생성 +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) +); From f1ea1827bc152288932956e951cd1342061249a4 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Sat, 2 Aug 2025 21:26:26 +0900 Subject: [PATCH 06/45] =?UTF-8?q?[SCRUM-212]=20=EA=B5=AD=EB=AC=B8=20?= =?UTF-8?q?=EA=B4=80=EA=B4=91=EC=A0=95=EB=B3=B4=20=EC=B8=A1=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=EB=B6=80=ED=84=B0=20=EB=B0=9B=EC=9D=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20URL=EC=9D=84=20AWS=20S3=EC=97=90=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [국문 관광정보] 측으로부터 받은 장소 이미지 URL을 AWS S3에 업로드하였습니다. 원본을 기준으로 하되, 없는 경우 썸네일을 사용하였습니다. --- .../controller/CourseDataController.java | 4 +- .../domain/course/entity/Spot.java | 8 + .../domain/course/entity/SpotImage.java | 12 ++ .../course/service/CourseDataService.java | 75 +++++++-- .../domain/course/service/FileService.java | 146 ++++++++++++++---- .../global/response/ResponseCode.java | 3 +- 6 files changed, 201 insertions(+), 47 deletions(-) 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 499148b..e0f6b52 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,7 +33,7 @@ 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)); } @@ -46,7 +46,7 @@ public ResponseEntity> updateSpots(@PathVariable Long courseId @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/entity/Spot.java b/src/main/java/com/server/running_handai/domain/course/entity/Spot.java index 3b06ff2..90bfedd 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Spot.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Spot.java @@ -62,4 +62,12 @@ public Spot(String externalId, String name, String address, String description, 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/course/entity/SpotImage.java b/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java index 006345d..ba06c05 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java @@ -3,6 +3,7 @@ import com.server.running_handai.global.entity.BaseTimeEntity; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,4 +27,15 @@ public class SpotImage extends BaseTimeEntity { @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/course/service/CourseDataService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java index c0bde47..650d5fc 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 @@ -14,6 +14,7 @@ import com.server.running_handai.domain.course.entity.*; import com.server.running_handai.domain.course.repository.*; import com.server.running_handai.domain.course.util.TrackPointSimplificationUtil; +import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import java.io.IOException; @@ -418,7 +419,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에 먼저 업로드 @@ -477,24 +478,20 @@ public void updateSpots(Long courseId) { .collect(Collectors.toSet()); externalIds.removeAll(existingIds); + // 정보가 없는 externalId만 보아 공통정보 조회 API 호출 for (String externalId : externalIds) { fetchSpot(externalId).ifPresent(item -> { - Spot spot = Spot.builder() - .externalId(item.getSpotExternalId()) - .name(item.getSpotName()) - .address(item.getSpotAddress()) - .description(item.getSpotDescription()) - .category(Category.findByCategoryNumber(item.getSpotCategoryNumber()) - .orElse(Category.UNKNOWN)) - .lat(Double.parseDouble(item.getSpotLatitude())) - .lon(Double.parseDouble(item.getSpotLongitude())) - .build(); - - spots.add(spot); + Spot spot = createSpot(item); + SpotImage spotImage; + spotImage = createSpotImage(item); + if (spotImage != null) { + spot.setSpotImage(spotImage); + } + spots.add(spot); }); } - // 3. 기존 데이터 일괄 삭제 + // 3. Course와 Spot의 연관관계 초기화 courseSpotRepository.deleteByCourseId(courseId); log.info("[즐길거리 수정] 기존 즐길거리 데이터 삭제 완료: courseId={}", courseId); @@ -518,7 +515,7 @@ public void updateSpots(Long courseId) { * @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. 코스 이름 조합 @@ -532,7 +529,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. 전체 거리 계산 @@ -919,4 +916,50 @@ public Optional fetchSpot(String externalId) { return Optional.of(items.getFirst()); } + + /** + * Spot 객체를 생성합니다. + * + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 Spot 객체 + */ + private Spot createSpot(SpotApiResponseDto.Item item) { + return Spot.builder() + .externalId(item.getSpotExternalId()) + .name(item.getSpotName()) + .address(item.getSpotAddress()) + .description(item.getSpotDescription()) + .category(Category.findByCategoryNumber(item.getSpotCategoryNumber()) + .orElse(Category.UNKNOWN)) + .lat(Double.parseDouble(item.getSpotLatitude())) + .lon(Double.parseDouble(item.getSpotLongitude())) + .build(); + } + + /** + * SpotImage 객체를 생성합니다. + * 원본을 우선 저장하고, 원본이 없는 경우 썸네일을 저장합니다. + * + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 SpotImage 객체, 없으면 null + */ + private SpotImage createSpotImage(SpotApiResponseDto.Item item) { + String originalImage = item.getSpotOriginalImage(); + String thumbnailImage = item.getSpotThumbnailImage(); + + if (originalImage != null && !originalImage.isBlank()) { + String s3FileUrl = fileService.uploadFileByUrl(originalImage, "image"); + return SpotImage.builder() + .imgUrl(s3FileUrl) + .originalUrl(originalImage) + .build(); + } else if (thumbnailImage != null && !thumbnailImage.isBlank()) { + String s3FileUrl = fileService.uploadFileByUrl(thumbnailImage, "image"); + return SpotImage.builder() + .imgUrl(s3FileUrl) + .originalUrl(thumbnailImage) + .build(); + } + return null; + } } 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..8ae4d7f 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; @@ -50,49 +52,64 @@ 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(); + if (originalFileName == null || originalFileName.isBlank()) { + originalFileName = "no-name"; + } + String fileName = directory + "/" + UUID.randomUUID() + "_" + originalFileName; String contentType = multipartFile.getContentType(); + if (contentType == null || contentType.isBlank()) { + contentType = guessContentType(originalFileName); + } + validateFileType(contentType, originalFileName); - 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("지원하지 않는 파일 형식입니다."); + 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 { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucket) - .key(fileName) - .contentType(contentType) - .build(); + URL url = new URL(fileUrl); + String path = url.getPath(); + String originalFileName = path.substring(path.lastIndexOf('/') + 1); + if (originalFileName == null || originalFileName.isBlank()) { + originalFileName = "no-name"; + } - 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; + String fileName = directory + "/" + UUID.randomUUID() + "_" + originalFileName; + String contentType = guessContentType(originalFileName); + validateFileType(contentType, originalFileName); + + 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 파일 업로드] 업로드 실패: 파일명={}, 대상경로={}", originalFileName, fileName, 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 +172,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 +184,76 @@ 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 contentType + * @param fileName 파일 이름 + */ + private void validateFileType(String contentType, String fileName) { + String lowerName = fileName.toLowerCase(); + + boolean isSupported = false; + if ((lowerName.endsWith(".png") && "image/png".equals(contentType)) + || ((lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) && "image/jpeg".equals(contentType)) + || (lowerName.endsWith(".gpx") && "application/gpx+xml".equals(contentType)) + || "application/octet-stream".equals(contentType)) { + isSupported = true; + } + + if (!isSupported) { + throw new BusinessException(ResponseCode.UNSUPPORTED_FILE_TYPE); + } + } + + /** + * 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/global/response/ResponseCode.java b/src/main/java/com/server/running_handai/global/response/ResponseCode.java index 426c7ca..9f4e452 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 @@ -63,7 +63,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; From 8274285f99237d847a62f24b351f0cbde595cca8 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Sun, 3 Aug 2025 23:34:19 +0900 Subject: [PATCH 07/45] =?UTF-8?q?[SCRUM-212]=20TEST:=20=EC=A6=90=EA=B8=B8?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=88=98=EC=A0=95=20API=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 즐길거리 수정 API의 서비스 메서드들에 대하여 테스트 코드를 구현하고 테스트를 진행했습니다. --- .../course/repository/SpotRepository.java | 2 - .../course/service/CourseDataService.java | 13 +- .../domain/course/service/FileService.java | 16 +- .../global/response/ResponseCode.java | 1 + src/main/resources/application.yml | 1 + .../course/service/CourseDataServiceTest.java | 311 ++++++++++++++++++ 6 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java diff --git a/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java index a28dd45..94abd9f 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java +++ b/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java @@ -5,9 +5,7 @@ import java.util.Collection; import java.util.List; -import java.util.Optional; public interface SpotRepository extends JpaRepository { - Optional findByExternalId(String externalId); List findByExternalIdIn(Collection externalIds); } \ No newline at end of file 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 650d5fc..3b87af6 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 @@ -423,7 +423,7 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) { Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); // 새 파일을 S3에 먼저 업로드 - String newImageUrl = fileService.uploadFile(courseImageFile, "image"); + String newImageUrl = fileService.uploadFile(courseImageFile, "course"); log.info("[코스 이미지 수정] S3 버킷에 이미지 업로드 완료: newImageUrl={}", newImageUrl); // 삭제할 기존 파일 URL을 임시 변수에 저장 @@ -451,7 +451,7 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) { */ @Transactional public void updateSpots(Long courseId) { - Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); List trackPoints = trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId()); TrackPoint startPoint = trackPoints.getFirst(); @@ -681,6 +681,11 @@ private List getTrackPoints(MultipartFile courseGpxFile) throws Exce .build()) .collect(Collectors.toList()); } + + if (trackPoints.isEmpty()) { + throw new BusinessException(TRACK_POINTS_NOT_FOUND); + } + return trackPoints; } @@ -948,13 +953,13 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { String thumbnailImage = item.getSpotThumbnailImage(); if (originalImage != null && !originalImage.isBlank()) { - String s3FileUrl = fileService.uploadFileByUrl(originalImage, "image"); + String s3FileUrl = fileService.uploadFileByUrl(originalImage, "spot"); return SpotImage.builder() .imgUrl(s3FileUrl) .originalUrl(originalImage) .build(); } else if (thumbnailImage != null && !thumbnailImage.isBlank()) { - String s3FileUrl = fileService.uploadFileByUrl(thumbnailImage, "image"); + String s3FileUrl = fileService.uploadFileByUrl(thumbnailImage, "spot"); return SpotImage.builder() .imgUrl(s3FileUrl) .originalUrl(thumbnailImage) 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 8ae4d7f..35aee78 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 @@ -63,7 +63,7 @@ public String uploadFile(MultipartFile multipartFile, String directory) { if (contentType == null || contentType.isBlank()) { contentType = guessContentType(originalFileName); } - validateFileType(contentType, originalFileName); + validateFileType(originalFileName); try { return uploadToS3(fileName, contentType, multipartFile.getInputStream(), multipartFile.getSize()); @@ -92,7 +92,7 @@ public String uploadFileByUrl(String fileUrl, String directory) { String fileName = directory + "/" + UUID.randomUUID() + "_" + originalFileName; String contentType = guessContentType(originalFileName); - validateFileType(contentType, originalFileName); + validateFileType(originalFileName); HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); httpURLConnection.setRequestMethod("GET"); @@ -209,19 +209,13 @@ private String guessContentType(String filename) { /** * 주어진 Content Type이 지원되는 타입인지 확인합니다. * - * @param contentType * @param fileName 파일 이름 */ - private void validateFileType(String contentType, String fileName) { + private void validateFileType(String fileName) { String lowerName = fileName.toLowerCase(); - boolean isSupported = false; - if ((lowerName.endsWith(".png") && "image/png".equals(contentType)) - || ((lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) && "image/jpeg".equals(contentType)) - || (lowerName.endsWith(".gpx") && "application/gpx+xml".equals(contentType)) - || "application/octet-stream".equals(contentType)) { - isSupported = true; - } + boolean isSupported = lowerName.endsWith(".png") || lowerName.endsWith(".jpg") || + lowerName.endsWith(".jpeg") || (lowerName.endsWith(".gpx")); if (!isSupported) { throw new BusinessException(ResponseCode.UNSUPPORTED_FILE_TYPE); 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 9f4e452..0e91229 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 @@ -52,6 +52,7 @@ public enum ResponseCode { // 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 요청입니다."), diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ad5d371..ae98a2b 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} diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java new file mode 100644 index 0000000..c1b149c --- /dev/null +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java @@ -0,0 +1,311 @@ +package com.server.running_handai.domain.course.service; + +import com.server.running_handai.domain.course.client.SpotApiClient; +import com.server.running_handai.domain.course.client.SpotLocationApiClient; +import com.server.running_handai.domain.course.dto.SpotApiResponseDto; +import com.server.running_handai.domain.course.dto.SpotLocationApiResponseDto; +import com.server.running_handai.domain.course.entity.Course; +import com.server.running_handai.domain.course.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.course.repository.CourseSpotRepository; +import com.server.running_handai.domain.course.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.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 CourseDataServiceTest { + + @InjectMocks + private CourseDataService courseDataService; + + @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; + + // [성공] + // 1. 모두 새로운 호출을 진행하는 경우 + @Test + @DisplayName("즐길거리 수정 성공 - 모두 새로운 호출 진행") + void updateSpots_success_allNewFetch() { + // given + Course course = createMockCourse(COURSE_ID); + TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); + TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); + 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 + courseDataService.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 + Course course = createMockCourse(COURSE_ID); + TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); + TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); + 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 + courseDataService.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 + Course course = createMockCourse(COURSE_ID); + TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); + TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); + 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 + courseDataService.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 + Course course = createMockCourse(COURSE_ID); + TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); + TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); + 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 + courseDataService.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 + Course course = createMockCourse(COURSE_ID); + TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); + TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); + + 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 + courseDataService.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, () -> courseDataService.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) { + Spot spot = Spot.builder().externalId(externalId).build(); + return spot; + } + + // 수정된 SpotLocationApiResponseDto 생성 메서드 + 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(); + + // 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 From e26c61962232b0db6575d2131021d53e81a74a05 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Sun, 3 Aug 2025 23:38:17 +0900 Subject: [PATCH 08/45] =?UTF-8?q?[SCRUM-212]=20COMMENT:=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/service/CourseDataServiceTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java index c1b149c..b6d1a0f 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java @@ -260,9 +260,7 @@ private Spot createMockSpot(String externalId) { return spot; } - // 수정된 SpotLocationApiResponseDto 생성 메서드 private SpotLocationApiResponseDto createSpotLocationApiResponse(Set externalIds) { - // 실제 객체를 생성하고 필요한 데이터만 설정 SpotLocationApiResponseDto dto = new SpotLocationApiResponseDto(); SpotLocationApiResponseDto.Response response = new SpotLocationApiResponseDto.Response(); SpotLocationApiResponseDto.Body body = new SpotLocationApiResponseDto.Body(); @@ -290,7 +288,6 @@ private SpotApiResponseDto createSpotApiResponse(String externalId) { SpotApiResponseDto.Items items = new SpotApiResponseDto.Items(); SpotApiResponseDto.Item item = new SpotApiResponseDto.Item(); - // Item 필드 설정 ReflectionTestUtils.setField(item, "spotExternalId", externalId); ReflectionTestUtils.setField(item, "spotName", "Test Spot"); ReflectionTestUtils.setField(item, "spotAddress", "Test Address"); From 081599167de8dca08ee17e2ff6c1e7be87349177 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 7 Aug 2025 18:27:53 +0900 Subject: [PATCH 09/45] =?UTF-8?q?[SCRUM-212]=20REFACTOR:=20Spot=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?SpotCategory=20Enum=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course 패키지 내 Spot 관련 코드를 독립된 Spot 패키지로 분리하여 구조를 명확히 했습니다. 추가적으로 Category Enum 이름을 직관적인 SpotCategory로 변경했습니다. --- .../controller/CourseDataController.java | 6 - .../domain/course/entity/Course.java | 1 + .../course/service/CourseDataService.java | 166 --------------- .../client/SpotApiClient.java | 4 +- .../client/SpotLocationApiClient.java | 4 +- .../spot/controller/SpotDataController.java | 24 +++ .../dto/SpotApiResponseDto.java | 2 +- .../dto/SpotLocationApiResponseDto.java | 2 +- .../{course => spot}/entity/CourseSpot.java | 3 +- .../domain/{course => spot}/entity/Spot.java | 8 +- .../entity/SpotCategory.java} | 10 +- .../{course => spot}/entity/SpotImage.java | 2 +- .../repository/CourseSpotRepository.java | 4 +- .../repository/SpotRepository.java | 4 +- .../domain/spot/service/SpotDataService.java | 198 ++++++++++++++++++ .../service/SpotDataServiceTest.java} | 90 ++++---- 16 files changed, 298 insertions(+), 230 deletions(-) rename src/main/java/com/server/running_handai/domain/{course => spot}/client/SpotApiClient.java (92%) rename src/main/java/com/server/running_handai/domain/{course => spot}/client/SpotLocationApiClient.java (94%) create mode 100644 src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java rename src/main/java/com/server/running_handai/domain/{course => spot}/dto/SpotApiResponseDto.java (97%) rename src/main/java/com/server/running_handai/domain/{course => spot}/dto/SpotLocationApiResponseDto.java (96%) rename src/main/java/com/server/running_handai/domain/{course => spot}/entity/CourseSpot.java (89%) rename src/main/java/com/server/running_handai/domain/{course => spot}/entity/Spot.java (90%) rename src/main/java/com/server/running_handai/domain/{course/entity/Category.java => spot/entity/SpotCategory.java} (85%) rename src/main/java/com/server/running_handai/domain/{course => spot}/entity/SpotImage.java (95%) rename src/main/java/com/server/running_handai/domain/{course => spot}/repository/CourseSpotRepository.java (78%) rename src/main/java/com/server/running_handai/domain/{course => spot}/repository/SpotRepository.java (67%) create mode 100644 src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java rename src/test/java/com/server/running_handai/domain/{course/service/CourseDataServiceTest.java => spot/service/SpotDataServiceTest.java} (86%) 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 e0f6b52..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 @@ -38,12 +38,6 @@ public ResponseEntity> updateCourseImage(@PathVariable Long co return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); } - @PutMapping("/{courseId}/spots") - public ResponseEntity> updateSpots(@PathVariable Long courseId) { - courseDataService.updateSpots(courseId); - 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) { 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 d05339f..270c485 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.global.entity.BaseTimeEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; 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 3b87af6..55fb9b5 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 @@ -7,8 +7,6 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.fasterxml.jackson.dataformat.xml.deser.FromXmlParser; import com.server.running_handai.domain.course.client.DurunubiApiClient; -import com.server.running_handai.domain.course.client.SpotApiClient; -import com.server.running_handai.domain.course.client.SpotLocationApiClient; import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.course.dto.DurunubiApiResponseDto.Item; import com.server.running_handai.domain.course.entity.*; @@ -17,7 +15,6 @@ 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; @@ -50,13 +47,9 @@ public class CourseDataService { private final ObjectMapper objectMapper; private final DurunubiApiClient durunubiApiClient; - private final SpotLocationApiClient spotLocationApiClient; - private final SpotApiClient spotApiClient; private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; private final RoadConditionRepository roadConditionRepository; - private final CourseSpotRepository courseSpotRepository; - private final SpotRepository spotRepository; private final KakaoMapService kakaoMapService; private final OpenAiService openAiService; private final FileService fileService; @@ -444,68 +437,6 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) { } } - /** - * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 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 수집 - // 조회 조건: 시작점, 출발점, 관광지(12), 음식점(39) - Set externalIds = new HashSet<>(); - - externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 12)); - externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 12)); - externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 39)); - externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 39)); - - 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 호출 - for (String externalId : externalIds) { - fetchSpot(externalId).ifPresent(item -> { - Spot spot = createSpot(item); - SpotImage 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 -> new CourseSpot(course, spot)) - .collect(Collectors.toList()); - - courseSpotRepository.saveAll(courseSpots); - log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 개수={}", courseId, spots.size()); - } - /** * GPX 파일을 받아 코스 정보를 생성하고 저장합니다. * OpenAI API의 경우, 예상 토큰 값을 계산하여 최대 토큰 값을 넘으면 RDP 단순화 알고리즘을 적용하여 요청합니다. @@ -870,101 +801,4 @@ private String convertTrackPointToJson(List trackPointDto return trackPointDtoList.toString(); } } - - /** - * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. - * - * @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() - .map(SpotLocationApiResponseDto.Item::getSpotExternalId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - } - - /** - * 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()); - } - - /** - * Spot 객체를 생성합니다. - * - * @param item 공통정보 조회 API로부터 받은 응답 - * @return 새로 생성된 Spot 객체 - */ - private Spot createSpot(SpotApiResponseDto.Item item) { - return Spot.builder() - .externalId(item.getSpotExternalId()) - .name(item.getSpotName()) - .address(item.getSpotAddress()) - .description(item.getSpotDescription()) - .category(Category.findByCategoryNumber(item.getSpotCategoryNumber()) - .orElse(Category.UNKNOWN)) - .lat(Double.parseDouble(item.getSpotLatitude())) - .lon(Double.parseDouble(item.getSpotLongitude())) - .build(); - } - - /** - * SpotImage 객체를 생성합니다. - * 원본을 우선 저장하고, 원본이 없는 경우 썸네일을 저장합니다. - * - * @param item 공통정보 조회 API로부터 받은 응답 - * @return 새로 생성된 SpotImage 객체, 없으면 null - */ - private SpotImage createSpotImage(SpotApiResponseDto.Item item) { - String originalImage = item.getSpotOriginalImage(); - String thumbnailImage = item.getSpotThumbnailImage(); - - if (originalImage != null && !originalImage.isBlank()) { - String s3FileUrl = fileService.uploadFileByUrl(originalImage, "spot"); - return SpotImage.builder() - .imgUrl(s3FileUrl) - .originalUrl(originalImage) - .build(); - } else if (thumbnailImage != null && !thumbnailImage.isBlank()) { - String s3FileUrl = fileService.uploadFileByUrl(thumbnailImage, "spot"); - return SpotImage.builder() - .imgUrl(s3FileUrl) - .originalUrl(thumbnailImage) - .build(); - } - return null; - } } diff --git a/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java b/src/main/java/com/server/running_handai/domain/spot/client/SpotApiClient.java similarity index 92% rename from src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java rename to src/main/java/com/server/running_handai/domain/spot/client/SpotApiClient.java index 62376fd..ea7fb69 100644 --- a/src/main/java/com/server/running_handai/domain/course/client/SpotApiClient.java +++ b/src/main/java/com/server/running_handai/domain/spot/client/SpotApiClient.java @@ -1,6 +1,6 @@ -package com.server.running_handai.domain.course.client; +package com.server.running_handai.domain.spot.client; -import com.server.running_handai.domain.course.dto.SpotApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotApiResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java b/src/main/java/com/server/running_handai/domain/spot/client/SpotLocationApiClient.java similarity index 94% rename from src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java rename to src/main/java/com/server/running_handai/domain/spot/client/SpotLocationApiClient.java index e6e3367..c1108a0 100644 --- a/src/main/java/com/server/running_handai/domain/course/client/SpotLocationApiClient.java +++ b/src/main/java/com/server/running_handai/domain/spot/client/SpotLocationApiClient.java @@ -1,6 +1,6 @@ -package com.server.running_handai.domain.course.client; +package com.server.running_handai.domain.spot.client; -import com.server.running_handai.domain.course.dto.SpotLocationApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotLocationApiResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; 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/course/dto/SpotApiResponseDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotApiResponseDto.java similarity index 97% rename from src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java rename to src/main/java/com/server/running_handai/domain/spot/dto/SpotApiResponseDto.java index a009f13..0cf1b7f 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/SpotApiResponseDto.java +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotApiResponseDto.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.dto; +package com.server.running_handai.domain.spot.dto; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/server/running_handai/domain/course/dto/SpotLocationApiResponseDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotLocationApiResponseDto.java similarity index 96% rename from src/main/java/com/server/running_handai/domain/course/dto/SpotLocationApiResponseDto.java rename to src/main/java/com/server/running_handai/domain/spot/dto/SpotLocationApiResponseDto.java index 548c6f9..bc8e9cb 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/SpotLocationApiResponseDto.java +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotLocationApiResponseDto.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.dto; +package com.server.running_handai.domain.spot.dto; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java b/src/main/java/com/server/running_handai/domain/spot/entity/CourseSpot.java similarity index 89% rename from src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java rename to src/main/java/com/server/running_handai/domain/spot/entity/CourseSpot.java index f69bca2..b7ded4c 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/CourseSpot.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/CourseSpot.java @@ -1,5 +1,6 @@ -package com.server.running_handai.domain.course.entity; +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; diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Spot.java b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java similarity index 90% rename from src/main/java/com/server/running_handai/domain/course/entity/Spot.java rename to src/main/java/com/server/running_handai/domain/spot/entity/Spot.java index 90bfedd..afc4985 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Spot.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.entity; +package com.server.running_handai.domain.spot.entity; import com.server.running_handai.global.entity.BaseTimeEntity; import jakarta.persistence.*; @@ -35,7 +35,7 @@ public class Spot extends BaseTimeEntity { @Enumerated(EnumType.STRING) @Column(name = "category", nullable = false) - private Category category; // 카테고리 + private SpotCategory spotCategory; // 카테고리 @Column(name = "lat", nullable = false) private double lat; // 위도 @@ -53,12 +53,12 @@ public class Spot extends BaseTimeEntity { @Builder public Spot(String externalId, String name, String address, String description, - Category category, double lat, double lon) { + SpotCategory spotCategory, double lat, double lon) { this.externalId = externalId; this.name = name; this.address = address; this.description = description; - this.category = category; + this.spotCategory = spotCategory; this.lat = lat; this.lon = lon; } diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Category.java b/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java similarity index 85% rename from src/main/java/com/server/running_handai/domain/course/entity/Category.java rename to src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java index 1538c83..060efc4 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Category.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.entity; +package com.server.running_handai.domain.spot.entity; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -9,7 +9,7 @@ @Getter @RequiredArgsConstructor -public enum Category { +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")), @@ -35,9 +35,9 @@ public enum Category { * @param categoryNumber 카테고리 번호 (예: "A01010100") * @return 일치하는 Category Enum을 Optional로 감싸서 반환, 없으면 Optional.empty() */ - public static Optional findByCategoryNumber(String categoryNumber) { - return Arrays.stream(Category.values()) - .filter(category -> category.getCategoryNumber().contains(categoryNumber)) + 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/course/entity/SpotImage.java b/src/main/java/com/server/running_handai/domain/spot/entity/SpotImage.java similarity index 95% rename from src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java rename to src/main/java/com/server/running_handai/domain/spot/entity/SpotImage.java index ba06c05..53b37bd 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/SpotImage.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/SpotImage.java @@ -1,4 +1,4 @@ -package com.server.running_handai.domain.course.entity; +package com.server.running_handai.domain.spot.entity; import com.server.running_handai.global.entity.BaseTimeEntity; import jakarta.persistence.*; diff --git a/src/main/java/com/server/running_handai/domain/course/repository/CourseSpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java similarity index 78% rename from src/main/java/com/server/running_handai/domain/course/repository/CourseSpotRepository.java rename to src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java index b7eb552..57a2a39 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/CourseSpotRepository.java +++ b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java @@ -1,6 +1,6 @@ -package com.server.running_handai.domain.course.repository; +package com.server.running_handai.domain.spot.repository; -import com.server.running_handai.domain.course.entity.CourseSpot; +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; diff --git a/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java similarity index 67% rename from src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java rename to src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java index 94abd9f..9b03922 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/SpotRepository.java +++ b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java @@ -1,6 +1,6 @@ -package com.server.running_handai.domain.course.repository; +package com.server.running_handai.domain.spot.repository; -import com.server.running_handai.domain.course.entity.Spot; +import com.server.running_handai.domain.spot.entity.Spot; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; 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..619f095 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java @@ -0,0 +1,198 @@ +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.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; + + /** + * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 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 수집 + // 조회 조건: 시작점, 출발점, 관광지(12), 음식점(39) + Set externalIds = new HashSet<>(); + + externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 12)); + externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 12)); + externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 39)); + externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 39)); + + 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 호출 + for (String externalId : externalIds) { + fetchSpot(externalId).ifPresent(item -> { + Spot spot = createSpot(item); + SpotImage 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 -> new CourseSpot(course, spot)) + .collect(Collectors.toList()); + + courseSpotRepository.saveAll(courseSpots); + log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 개수={}", courseId, spots.size()); + } + + /** + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. + * + * @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() + .map(SpotLocationApiResponseDto.Item::getSpotExternalId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + /** + * 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()); + } + + /** + * Spot 객체를 생성합니다. + * + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 Spot 객체 + */ + private Spot createSpot(SpotApiResponseDto.Item item) { + return 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(); + } + + /** + * SpotImage 객체를 생성합니다. + * 원본을 우선 저장하고, 원본이 없는 경우 썸네일을 저장합니다. + * + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 SpotImage 객체, 없으면 null + */ + private SpotImage createSpotImage(SpotApiResponseDto.Item item) { + String originalImage = item.getSpotOriginalImage(); + String thumbnailImage = item.getSpotThumbnailImage(); + + if (originalImage != null && !originalImage.isBlank()) { + String s3FileUrl = fileService.uploadFileByUrl(originalImage, "spot"); + return SpotImage.builder() + .imgUrl(s3FileUrl) + .originalUrl(originalImage) + .build(); + } else if (thumbnailImage != null && !thumbnailImage.isBlank()) { + String s3FileUrl = fileService.uploadFileByUrl(thumbnailImage, "spot"); + return SpotImage.builder() + .imgUrl(s3FileUrl) + .originalUrl(thumbnailImage) + .build(); + } + return null; + } +} diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java b/src/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java similarity index 86% rename from src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java rename to src/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java index b6d1a0f..4ebb85e 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseDataServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java @@ -1,18 +1,20 @@ -package com.server.running_handai.domain.course.service; +package com.server.running_handai.domain.spot.service; -import com.server.running_handai.domain.course.client.SpotApiClient; -import com.server.running_handai.domain.course.client.SpotLocationApiClient; -import com.server.running_handai.domain.course.dto.SpotApiResponseDto; -import com.server.running_handai.domain.course.dto.SpotLocationApiResponseDto; +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.course.entity.Spot; +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.course.repository.CourseSpotRepository; -import com.server.running_handai.domain.course.repository.SpotRepository; +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; @@ -32,10 +34,10 @@ @ActiveProfiles("test") @ExtendWith(MockitoExtension.class) -class CourseDataServiceTest { +class SpotDataServiceTest { @InjectMocks - private CourseDataService courseDataService; + private SpotDataService spotDataService; @Mock private CourseRepository courseRepository; @@ -59,16 +61,26 @@ class CourseDataServiceTest { private FileService fileService; private static final Long COURSE_ID = 1L; + private Course course; + private TrackPoint startPoint; + private TrackPoint endPoint; - // [성공] - // 1. 모두 새로운 호출을 진행하는 경우 + + @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 - Course course = createMockCourse(COURSE_ID); - TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); - TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); Set externalIds = Set.of("externalId1"); given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); @@ -84,7 +96,7 @@ void updateSpots_success_allNewFetch() { given(fileService.uploadFileByUrl(anyString(), eq("spot"))).willReturn("https://mock-s3-url.com/externalId1.png"); // when - courseDataService.updateSpots(COURSE_ID); + spotDataService.updateSpots(COURSE_ID); // then verify(courseRepository).findById(COURSE_ID); @@ -98,14 +110,14 @@ void updateSpots_success_allNewFetch() { verify(courseSpotRepository).saveAll(anyList()); } - // 2. SpotImage가 Null일 경우 + /** + * [즐길거리 수정] 성공 + * 2. SpotImage가 Null일 경우 + */ @Test @DisplayName("즐길거리 수정 성공 - SpotImage가 Null") void updateSpots_success_noSpotImage() { // given - Course course = createMockCourse(COURSE_ID); - TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); - TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); Set externalIds = Set.of("externalId1"); given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); @@ -124,7 +136,7 @@ void updateSpots_success_noSpotImage() { given(spotApiClient.fetchSpotData(anyString())).willReturn(spotApiResponseDto); // when - courseDataService.updateSpots(COURSE_ID); + spotDataService.updateSpots(COURSE_ID); // then // uploadFileByUrl이 아예 호출되지 않는지 확인 @@ -134,14 +146,14 @@ void updateSpots_success_noSpotImage() { verify(courseSpotRepository).saveAll(anyList()); } - // 3. Spot 일부가 DB에 존재하는 경우 + /** + * [즐길거리 수정] 성공 + * 3. Spot 일부가 DB에 존재하는 경우 + */ @Test @DisplayName("즐길거리 수정 성공 - Spot 일부가 DB에 존재") void updateSpots_success_existingSpots() { // given - Course course = createMockCourse(COURSE_ID); - TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); - TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); Set externalIds = Set.of("externalId1", "externalId2"); Spot existingSpot1 = createMockSpot("externalId1"); @@ -159,7 +171,7 @@ void updateSpots_success_existingSpots() { given(fileService.uploadFileByUrl(anyString(), eq("spot"))).willReturn("https://mock-s3-url.com/externalId2.png"); // when - courseDataService.updateSpots(COURSE_ID); + spotDataService.updateSpots(COURSE_ID); // then // externalId2만 공통정보 조회 API 호출하고, externalId1은 호출하지 않는지 확인 @@ -171,7 +183,10 @@ void updateSpots_success_existingSpots() { verify(courseSpotRepository).saveAll(anyList()); } - // 4. SpotApiResponseDto가 Null인 경우 + /** + * [즐길거리 수정] 성공 + * 4. SpotApiResponseDto가 Null인 경우 + */ @Test @DisplayName("즐길거리 수정 성공 - SpotApiResponseDto가 Null") void updateSpots_success_noSpotApiResponseDto() { @@ -193,7 +208,7 @@ void updateSpots_success_noSpotApiResponseDto() { given(spotApiClient.fetchSpotData(anyString())).willReturn(null); // when - courseDataService.updateSpots(COURSE_ID); + spotDataService.updateSpots(COURSE_ID); // then // 공통정보 조회 API 호출은 되지만, 이미지 업로드는 실행되지 않고, 빈 리스트가 저장되는지 확인 @@ -204,15 +219,14 @@ void updateSpots_success_noSpotApiResponseDto() { verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); } - // 5. SpotLocationApiResponseDto가 Null인 경우 + /** + * [즐길거리 수정] 성공 + * 5. SpotLocationApiResponseDto가 Null인 경우 + */ @Test @DisplayName("즐길거리 수정 성공 - SpotLocationApiResponseDto가 Null") void updateSpots_success_noSpotLocationApiResponseDto() { // given - Course course = createMockCourse(COURSE_ID); - TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); - TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); - given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); given(trackPointRepository.findByCourseIdOrderBySequenceAsc(course.getId())).willReturn(List.of(startPoint, endPoint)); @@ -223,7 +237,7 @@ void updateSpots_success_noSpotLocationApiResponseDto() { given(spotRepository.findByExternalIdIn(anySet())).willReturn(Collections.emptyList()); // when - courseDataService.updateSpots(COURSE_ID); + spotDataService.updateSpots(COURSE_ID); // then // 위치기반 정보조회 API 호출은 되지만, 공통정보 조회 API와 이미지 업로드는 실행되지 않고, 빈 리스트가 저장되는지 확인 @@ -235,8 +249,10 @@ void updateSpots_success_noSpotLocationApiResponseDto() { verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); } - // [실패] - // 1. Course가 없는 경우 + /** + * [즐길거리 수정] 실패 + * 1. Course가 없는 경우 + */ @Test @DisplayName("즐길거리 수정 실패 - Course가 없음") void updateSpots_fail_courseNotFound() { @@ -244,7 +260,7 @@ void updateSpots_fail_courseNotFound() { given(courseRepository.findById(COURSE_ID)).willReturn(Optional.empty()); // when, then - BusinessException exception = assertThrows(BusinessException.class, () -> courseDataService.updateSpots(COURSE_ID)); + BusinessException exception = assertThrows(BusinessException.class, () -> spotDataService.updateSpots(COURSE_ID)); assertThat(exception.getResponseCode()).isEqualTo(ResponseCode.COURSE_NOT_FOUND); } From a58c9395529a828cb911268554ec086f12d88812 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 7 Aug 2025 18:51:05 +0900 Subject: [PATCH 10/45] =?UTF-8?q?[SCRUM-212]=20REFACTOR:=20RoadCondition,?= =?UTF-8?q?=20CourseImage,=20Spot=20=EA=B0=9D=EC=B2=B4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20Builder=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통일성을 위해 RoadCondition, CourseImage, Spot 클래스의 객체 생성 코드를 생성자 호출 방식에서 Builder 패턴으로 변경했습니다. --- .../domain/course/service/CourseDataService.java | 7 ++++--- .../domain/spot/service/SpotDataService.java | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) 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 55fb9b5..3e4dffb 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 @@ -397,7 +397,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); @@ -426,7 +427,7 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) { 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); @@ -544,7 +545,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); 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 index 619f095..27d949a 100644 --- 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 @@ -92,7 +92,8 @@ public void updateSpots(Long courseId) { List newSpots = spotRepository.saveAll(spots); List courseSpots = newSpots.stream() - .map(spot -> new CourseSpot(course, spot)) + .map(spot -> + CourseSpot.builder().course(course).spot(spot).build()) .collect(Collectors.toList()); courseSpotRepository.saveAll(courseSpots); From d60bf53521a1c6ce11090fa53bf123ce617d091e Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 7 Aug 2025 20:59:16 +0900 Subject: [PATCH 11/45] =?UTF-8?q?[SCRUM-212]=20FEAT:=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EA=B0=92=20=ED=95=84=EB=93=9C=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [국문 관광정보] 공통정보 조회 API와 위치기반 관광정보 API의 응답값 필드에 대해 유효하지 않은 경우 로그를 남기고 데이터베이스에는 저장되지 않도록 처리했습니다. 관련 테스트 코드도 함께 추가했습니다. --- .../domain/spot/service/SpotDataService.java | 92 ++++++++++++++-- .../spot/service/SpotDataServiceTest.java | 104 +++++++++++++++++- 2 files changed, 181 insertions(+), 15 deletions(-) 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 index 27d949a..0b95194 100644 --- 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 @@ -74,13 +74,14 @@ public void updateSpots(Long courseId) { // 정보가 없는 externalId만 보아 공통정보 조회 API 호출 for (String externalId : externalIds) { fetchSpot(externalId).ifPresent(item -> { - Spot spot = createSpot(item); - SpotImage spotImage; - spotImage = createSpotImage(item); - if (spotImage != null) { - spot.setSpotImage(spotImage); - } - spots.add(spot); + Optional spotOptional = createSpot(item); + spotOptional.ifPresent(spot -> { + SpotImage spotImage = createSpotImage(item); + if (spotImage != null) { + spot.setSpotImage(spotImage); + } + spots.add(spot); + }); }); } @@ -123,8 +124,8 @@ private Set fetchSpotsByLocation(double lon, double lat, int contentType } return items.stream() + .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) .map(SpotLocationApiResponseDto.Item::getSpotExternalId) - .filter(Objects::nonNull) .collect(Collectors.toSet()); } @@ -157,8 +158,38 @@ public Optional fetchSpot(String externalId) { * @param item 공통정보 조회 API로부터 받은 응답 * @return 새로 생성된 Spot 객체 */ - private Spot createSpot(SpotApiResponseDto.Item item) { - return Spot.builder() + 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()) @@ -168,6 +199,8 @@ private Spot createSpot(SpotApiResponseDto.Item item) { .lat(Double.parseDouble(item.getSpotLatitude())) .lon(Double.parseDouble(item.getSpotLongitude())) .build(); + + return Optional.of(spot); } /** @@ -181,13 +214,13 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { String originalImage = item.getSpotOriginalImage(); String thumbnailImage = item.getSpotThumbnailImage(); - if (originalImage != null && !originalImage.isBlank()) { + if (isFieldValid(originalImage, "spotOriginalImage", item.getSpotExternalId())) { String s3FileUrl = fileService.uploadFileByUrl(originalImage, "spot"); return SpotImage.builder() .imgUrl(s3FileUrl) .originalUrl(originalImage) .build(); - } else if (thumbnailImage != null && !thumbnailImage.isBlank()) { + } else if (isFieldValid(thumbnailImage, "spotThumbnailImage", item.getSpotExternalId())) { String s3FileUrl = fileService.uploadFileByUrl(thumbnailImage, "spot"); return SpotImage.builder() .imgUrl(s3FileUrl) @@ -196,4 +229,39 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { } 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 + * @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/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java b/src/test/java/com/server/running_handai/domain/spot/service/SpotDataServiceTest.java index 4ebb85e..cf79c56 100644 --- 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 @@ -191,9 +191,6 @@ void updateSpots_success_existingSpots() { @DisplayName("즐길거리 수정 성공 - SpotApiResponseDto가 Null") void updateSpots_success_noSpotApiResponseDto() { // given - Course course = createMockCourse(COURSE_ID); - TrackPoint startPoint = TrackPoint.builder().lon(127.1).lat(37.1).build(); - TrackPoint endPoint = TrackPoint.builder().lon(127.2).lat(37.2).build(); Set externalIds = Set.of("externalId1"); given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); @@ -249,6 +246,107 @@ void updateSpots_success_noSpotLocationApiResponseDto() { verify(courseSpotRepository).saveAll(argThat(list -> ((Collection) list).isEmpty())); } + /** + * [즐길거리 수정] 성공 + * 6. 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())); + } + + /** + * [즐길거리 수정] 성공 + * 7. 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가 없는 경우 From 33ff2969bc753e3dd048d55b3159185048c44ee1 Mon Sep 17 00:00:00 2001 From: ssggii Date: Sat, 9 Aug 2025 16:45:00 +0900 Subject: [PATCH 12/45] =?UTF-8?q?FIX:=20=EB=91=90=EB=A3=A8=EB=88=84?= =?UTF-8?q?=EB=B9=84=20=EB=8F=99=EA=B8=B0=ED=99=94=20API=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95=20(#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두루누비 코스 데이터 동기화 중 새로운 코스 생성 시, 길 상태 업데이트까지 함께 실행되도록 로직을 수정했습니다. --- .../course/service/CourseDataService.java | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) 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..1015b70 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 @@ -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 삭제 From 3aaa0a82ee3c3d8ecacd64e603431294d66deba8 Mon Sep 17 00:00:00 2001 From: ssggii Date: Sat, 9 Aug 2025 17:10:36 +0900 Subject: [PATCH 13/45] =?UTF-8?q?FEAT:=20=EB=91=90=EB=A3=A8=EB=88=84?= =?UTF-8?q?=EB=B9=84=20=EC=BD=94=EC=8A=A4=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=93=B1=EB=A1=9D=20(#9?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매일 새벽 4시 30분에 두루누비 코스 동기화 작업이 실행되도록 스케줄러를 생성했습니다. --- .../RunningHandaiApplication.java | 2 ++ .../course/scheduler/CourseScheduler.java | 31 +++++++++++++++++++ .../course/service/CourseDataService.java | 2 +- .../util/TrackPointSimplificationUtil.java | 2 +- 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/course/scheduler/CourseScheduler.java rename src/main/java/com/server/running_handai/{domain/course => global}/util/TrackPointSimplificationUtil.java (97%) 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/course/scheduler/CourseScheduler.java b/src/main/java/com/server/running_handai/domain/course/scheduler/CourseScheduler.java new file mode 100644 index 0000000..6a60148 --- /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시 0분 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 1015b70..36c5b62 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,7 +19,7 @@ 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.exception.BusinessException; import java.io.IOException; 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; From 0e09bfa6a902178f3aa17d623bc151bb3e9809a4 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Sat, 9 Aug 2025 17:19:26 +0900 Subject: [PATCH 14/45] =?UTF-8?q?[SCRUM-212]=20FEAT:=20API=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B3=91=EB=A0=AC=20=EC=B2=98=EB=A6=AC=EB=90=98?= =?UTF-8?q?=EA=B2=8C=20=EC=B6=94=EA=B0=80=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 응답 시간 단축을 위해 [국문 관광정보] 공통정보 조회 API와 위치기반 관광정보 API를 호출할 때 병렬 처리되게 추가했습니다. 추가적으로 API 호출이 일부만 성공하는 경우의 테스트 코드를 추가했습니다. --- .../domain/spot/client/SpotApiClient.java | 2 +- .../spot/client/SpotLocationApiClient.java | 2 +- .../domain/spot/service/SpotDataService.java | 107 +++++++++++++----- .../spot/service/SpotDataServiceTest.java | 86 +++++++++++++- 4 files changed, 164 insertions(+), 33 deletions(-) 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 index ea7fb69..171fb2d 100644 --- 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 @@ -34,7 +34,7 @@ public SpotApiResponseDto fetchSpotData(String contentId) { .path("/detailCommon2") .queryParam("MobileOS", "ETC") .queryParam("MobileApp", "runninghandai") - .queryParam("_type", "Json") + .queryParam("_type", "json") .queryParam("contentId", contentId) .queryParam("serviceKey", serviceKey); 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 index c1108a0..662921a 100644 --- 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 @@ -51,7 +51,7 @@ public SpotLocationApiResponseDto fetchSpotLocationData( .queryParam("pageNo", pageNo) .queryParam("MobileOS", "ETC") .queryParam("MobileApp", "runninghandai") - .queryParam("_type", "Json") + .queryParam("_type", "json") .queryParam("arrange", arrange) .queryParam("mapX", String.valueOf(lon)) .queryParam("mapY", String.valueOf(lat)) 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 index 0b95194..223bec3 100644 --- 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 @@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.*; +import java.util.concurrent.Callable; import java.util.stream.Collectors; @Slf4j @@ -37,6 +38,10 @@ public class SpotDataService { 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를 통해 가져옵니다. * @@ -51,14 +56,7 @@ public void updateSpots(Long courseId) { TrackPoint endPoint = trackPoints.getLast(); // 1. 장소 externalId 수집 - // 조회 조건: 시작점, 출발점, 관광지(12), 음식점(39) - Set externalIds = new HashSet<>(); - - externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 12)); - externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 12)); - externalIds.addAll(fetchSpotsByLocation(startPoint.getLon(), startPoint.getLat(), 39)); - externalIds.addAll(fetchSpotsByLocation(endPoint.getLon(), endPoint.getLat(), 39)); - + Set externalIds = fetchSpotsByLocationInParallel(startPoint, endPoint); log.info("[즐길거리 수정] 수집된 고유 externalId 개수: {}", externalIds.size()); // 2. 수집된 externalId로 장소 정보 수집 @@ -71,17 +69,18 @@ public void updateSpots(Long courseId) { .collect(Collectors.toSet()); externalIds.removeAll(existingIds); - // 정보가 없는 externalId만 보아 공통정보 조회 API 호출 - for (String externalId : externalIds) { - fetchSpot(externalId).ifPresent(item -> { - Optional spotOptional = createSpot(item); - spotOptional.ifPresent(spot -> { - SpotImage spotImage = createSpotImage(item); - if (spotImage != null) { - spot.setSpotImage(spotImage); - } - spots.add(spot); - }); + // 정보가 없는 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); }); } @@ -103,11 +102,12 @@ public void updateSpots(Long courseId) { /** * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. + * API 응답값의 spotExternalId가 유효하지 않을 경우, Set에 포함하지 않습니다. * * @param lon 경도 (x) * @param lat 위도 (y) * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) - * @return externalId의 Set + * @return 유효한 externalId의 Set */ private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { SpotLocationApiResponseDto spotLocationApiResponseDto = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, contentTypeId); @@ -130,7 +130,7 @@ private Set fetchSpotsByLocation(double lon, double lat, int contentType } /** - * externalId에 대해 상세 SpotApiResponseDto.Item을 조회해 반환합니다. + * [국문 관광정보] 공통정보 조회 API를 요청해 externalId에 대한 SpotApiResponseDto.Item을 반환합니다. * * @param externalId 장소 고유번호 * @return SpotApiResponseDto.Item 정보, 없으면 Optional.empty() @@ -152,11 +152,62 @@ public Optional fetchSpot(String externalId) { 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 새로 생성된 Spot 객체 + * @return 생성된 Optional Spot, 유효하지 않으면 Optional.empty */ private Optional createSpot(SpotApiResponseDto.Item item) { if (!isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) { @@ -205,7 +256,7 @@ private Optional createSpot(SpotApiResponseDto.Item item) { /** * SpotImage 객체를 생성합니다. - * 원본을 우선 저장하고, 원본이 없는 경우 썸네일을 저장합니다. + * originalImage를 우선 저장하고, originalImage이 없는 경우 thumbnailImage를 저장합니다. * * @param item 공통정보 조회 API로부터 받은 응답 * @return 새로 생성된 SpotImage 객체, 없으면 null @@ -234,9 +285,9 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { * API 응답값의 필드가 null이거나 빈 문자열인지 검사합니다. * null 또는 빈 문자열일 경우 객체를 생성하지 않고 넘어갑니다. * - * @param value - * @param fieldName - * @param externalId + * @param value 확인할 문자열 + * @param fieldName 필드 이름 (로그용) + * @param externalId 장소 고유번호 (로그용) * @return null 혹은 빈 문자열인 경우 false, 아니면 true */ private boolean isFieldValid(String value, String fieldName, String externalId) { @@ -252,7 +303,9 @@ private boolean isFieldValid(String value, String fieldName, String externalId) * API 응답값에서 문자열로 들어오지만 Double로 저장되어야 하는 필드가 제대로 변환되는지 검사합니다. * 변환되지 않는 경우 객체를 생성하지 않고 넘어갑니다. * - * @param doubleString + * @param doubleString 확인할 문자열 + * @param fieldName 필드 이름 (로그용) + * @param externalId 장소 고유번호 (로그용) * @return Double로 변환되면 true, 아니면 false */ private boolean isFieldDouble(String doubleString, String fieldName, String externalId) { 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 index cf79c56..cfb516b 100644 --- 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 @@ -248,7 +248,86 @@ void updateSpots_success_noSpotLocationApiResponseDto() { /** * [즐길거리 수정] 성공 - * 6. SpotApiResponseDto의 필드값이 유효하지 않은 값일 경우 + * 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로 변환 불가 @@ -306,7 +385,7 @@ void updateSpots_success_invalidSpotApiResponseDtoField() { /** * [즐길거리 수정] 성공 - * 7. SpotLocationApiResponseDto의 필드값이 유효하지 않은 값일 경우 + * 9. SpotLocationApiResponseDto의 필드값이 유효하지 않은 값일 경우 * - Null * - 빈 문자열 */ @@ -370,8 +449,7 @@ private Course createMockCourse(Long courseId) { } private Spot createMockSpot(String externalId) { - Spot spot = Spot.builder().externalId(externalId).build(); - return spot; + return Spot.builder().externalId(externalId).build(); } private SpotLocationApiResponseDto createSpotLocationApiResponse(Set externalIds) { From 7c240c4eb867c747246433cad77dba6ffab8e2bd Mon Sep 17 00:00:00 2001 From: ssggii Date: Sun, 10 Aug 2025 16:17:34 +0900 Subject: [PATCH 15/45] =?UTF-8?q?COMMENT:=20CourseScheduler=EC=9D=98=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95=20(#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../running_handai/domain/course/scheduler/CourseScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6a60148..319ed1b 100644 --- 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 @@ -17,7 +17,7 @@ public class CourseScheduler { * 매일 새벽 4시에 코스 데이터 동기화 작업을 실행합니다. * cron = "[초] [분] [시] [일] [월] [요일]" */ - @Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul") // 매일 새벽 4시 0분 0초 + @Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul") // 매일 새벽 4시 30분 0초 public void scheduleDurunubiCourseSync() { log.info("[스케줄러] 두루누비 코스 동기화 작업을 시작합니다."); try { From fb9e8d5a5d9d2d1b56f5c4bfa0c75a8fbbe27ed6 Mon Sep 17 00:00:00 2001 From: ssggii Date: Sun, 10 Aug 2025 18:03:28 +0900 Subject: [PATCH 16/45] =?UTF-8?q?[SCRUM-235]=20=EB=82=B4=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#9?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지에서 사용할 내 리뷰 조회 API를 구현했습니다. - MyReviewInfoDto 생성 - ReviewController.getMyReviews() 구현 - ReviewService.getMyReviews() 구현 - ReviewRepository.findReviewsWithDetailsByMemberId() 구현 --- .../review/controller/ReviewController.java | 21 +++++++++- .../domain/review/dto/MyReviewInfoDto.java | 39 +++++++++++++++++++ .../review/repository/ReviewRepository.java | 9 +++++ .../domain/review/service/ReviewService.java | 21 ++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/server/running_handai/domain/review/dto/MyReviewInfoDto.java 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..77bf32f 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,22 @@ 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/me/reviews") + public ResponseEntity>> getMyReviews( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + List responseData = reviewService.getMyReviews(customOAuth2User.getMember().getId()); + + 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..129edec --- /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, + double 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(), + 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/repository/ReviewRepository.java b/src/main/java/com/server/running_handai/domain/review/repository/ReviewRepository.java index 1a2e6c2..10a1262 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 @@ -29,4 +29,13 @@ public interface ReviewRepository extends JpaRepository { */ @Query("SELECT AVG(r.stars) FROM Review r WHERE r.course.id = :courseId") Double findAverageStarsByCourseId(@Param("courseId") 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") + 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..729961e 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; @@ -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()); + } + } From 016c443ef2b9f02f84315ba406982dac29eac810 Mon Sep 17 00:00:00 2001 From: ssggii Date: Sun, 10 Aug 2025 20:22:23 +0900 Subject: [PATCH 17/45] =?UTF-8?q?[SCRUM-235]=20TEST:=20=EB=82=B4=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 리뷰 조회 API의 서비스 메서드에 대하여 테스트 코드를 구현했습니다. 테스트 케이스는 다음과 같습니다. - 내 리뷰 조회 성공 (작성한 리뷰가 있을 때) - 내 리뷰 조회 성공 (작성한 리뷰가 없을 때) --- .../review/service/ReviewServiceTest.java | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) 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..6fca5d6 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(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 From f4dda9f084d6aeae86a15ee67bcdd4ab2d7ffdf0 Mon Sep 17 00:00:00 2001 From: ssggii Date: Sun, 10 Aug 2025 22:56:02 +0900 Subject: [PATCH 18/45] =?UTF-8?q?[SCRUM-236]=20FEAT:=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=ED=95=9C=20=EC=BD=94=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지용 북마크한 코스 조회 API를 구현했습니다. --- .../controller/BookmarkController.java | 32 ++++++++++++++-- .../bookmark/dto/BookmarkedCourseInfoDto.java | 32 ++++++++++++++++ .../repository/BookmarkRepository.java | 38 ++++++++++++++++++- .../bookmark/service/BookmarkService.java | 26 +++++++++++++ .../global/response/ResponseCode.java | 1 + src/main/resources/application.yml | 4 ++ 6 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkedCourseInfoDto.java 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..44f09e7 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/me/courses/bookmark") + 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/bookmark/dto/BookmarkedCourseInfoDto.java b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkedCourseInfoDto.java new file mode 100644 index 0000000..fd80ca6 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/bookmark/dto/BookmarkedCourseInfoDto.java @@ -0,0 +1,32 @@ +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(); + double getDistance(); + 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/repository/BookmarkRepository.java b/src/main/java/com/server/running_handai/domain/bookmark/repository/BookmarkRepository.java index 25a999d..158f075 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.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; @@ -30,8 +32,42 @@ public interface BookmarkRepository extends JpaRepository { + "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 distance, " + + "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" + ) + List findBookmarkedCoursesByMemberId(Long memberId); + + // 사용자가 북마크한 코스 중에서 특정 지역의 코스만 조회 + @Query("SELECT " + + "b.id AS bookmarkId, " + + "c.id AS courseId, " + + "ci.imgUrl AS thumbnailUrl, " + + "c.distance AS distance, " + + "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" + ) + 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/global/response/ResponseCode.java b/src/main/java/com/server/running_handai/global/response/ResponseCode.java index 14e7aad..5642b7f 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) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d34d0f1..0984ea2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -91,6 +91,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 From 3dc59a860216ad8b4585e06a5092773c06754721 Mon Sep 17 00:00:00 2001 From: ssggii Date: Mon, 11 Aug 2025 20:53:44 +0900 Subject: [PATCH 19/45] =?UTF-8?q?[SCRUM-236]=20TEST:=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=ED=95=9C=20=EC=BD=94=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 북마크한 코스 조회 API의 서비스 메서드에 대하여 테스트 코드를 구현했습니다. 테스트 케이스는 다음과 같습니다. - 북마크한 코스 조회 성공 (전체 지역, 북마크한 코스 있음) - 북마크한 코스 조회 성공 (특정 지역, 북마크한 코스 있음) - 북마크한 코스 조회 성공 (전체 지역, 북마크한 코스 없음) - 북마크한 코스 조회 성공 (특정 지역, 북마크한 코스 없음) --- .../bookmark/service/BookmarkServiceTest.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) 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 From 01b45af7315c04c8caeba1878377324b0596274c Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Mon, 11 Aug 2025 22:04:51 +0900 Subject: [PATCH 20/45] =?UTF-8?q?[SCRUM-228]=20FEAT:=20=EC=A6=90=EA=B8=B8?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 특정 코스의 즐길거리 전체 정보를 조회하는 API를 구현했습니다. 장소의 이름, 설명, 이미지를 응답합니다. --- .../spot/controller/SpotController.java | 44 +++++++++++++++++ .../domain/spot/dto/SpotDetailDto.java | 10 ++++ .../domain/spot/dto/SpotInfoDto.java | 9 ++++ .../domain/spot/service/SpotService.java | 47 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 src/main/java/com/server/running_handai/domain/spot/controller/SpotController.java create mode 100644 src/main/java/com/server/running_handai/domain/spot/dto/SpotDetailDto.java create mode 100644 src/main/java/com/server/running_handai/domain/spot/dto/SpotInfoDto.java create mode 100644 src/main/java/com/server/running_handai/domain/spot/service/SpotService.java 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/dto/SpotDetailDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotDetailDto.java new file mode 100644 index 0000000..90aaef3 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotDetailDto.java @@ -0,0 +1,10 @@ +package com.server.running_handai.domain.spot.dto; + +import java.util.List; + +public record SpotDetailDto ( + long courseId, + int spotCount, + List 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..d4c6fdb --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotInfoDto.java @@ -0,0 +1,9 @@ +package com.server.running_handai.domain.spot.dto; + +public record SpotInfoDto( + long spotId, + String name, + String description, + String imageUrl +) { +} 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..926ff71 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotService.java @@ -0,0 +1,47 @@ +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) { + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + List spots = spotRepository.findByCourseId(course.getId()); + + List spotInfoDtos = spots.stream() + .map(spot -> new SpotInfoDto( + spot.getId(), + spot.getName(), + spot.getDescription(), + spot.getSpotImage() != null ? spot.getSpotImage().getImgUrl() : null + )) + .toList(); + + return new SpotDetailDto(courseId, spots.size(), spotInfoDtos); + } +} From 0b3263b4b1fffc78a9db7d1ec54983cfc6a60903 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Mon, 11 Aug 2025 22:06:40 +0900 Subject: [PATCH 21/45] =?UTF-8?q?[SCRUM-228]=20TEST:=20=EC=A6=90=EA=B8=B8?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 즐길거리 전체 조회 API의 서비스 메서드들에 대하여 테스트 코드를 구현하고 테스트를 진행했습니다. (성공 2개, 실패 1개) --- .../domain/spot/service/SpotServiceTest.java | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/test/java/com/server/running_handai/domain/spot/service/SpotServiceTest.java 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..028a90a --- /dev/null +++ b/src/test/java/com/server/running_handai/domain/spot/service/SpotServiceTest.java @@ -0,0 +1,148 @@ +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.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) +public class SpotServiceTest { + @InjectMocks + private SpotService spotService; + + @Mock + private CourseRepository courseRepository; + + @Mock + private SpotRepository spotRepository; + + private static final Long COURSE_ID = 1L; + private Course course; + + @BeforeEach + void setUp() { + course = createMockCourse(COURSE_ID); + } + + /** + * [즐길거리 전체 조회] 성공 + * 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.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(spotRepository.findByCourseId(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).findById(COURSE_ID); + verify(spotRepository).findByCourseId(COURSE_ID); + } + + /** + * [즐길거리 전체 조회] 성공 + * 2. Course에 해당되는 Spot이 존재하지 않는 경우 + */ + @Test + @DisplayName("즐길거리 전체 조회 - Spot이 존재하지 않음") + void getSpotDetails_success_noSpot() { + // given + // Spot이 존재하지 않으면 빈 리스트로 응답해야 함 + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(spotRepository.findByCourseId(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).findById(COURSE_ID); + verify(spotRepository).findByCourseId(course.getId()); + } + + /** + * [즐길거리 전체 조회] 실패 + * 1. Course가 존재하지 않을 경우 + */ + @Test + @DisplayName("즐길거리 전체 조회 - 존재하지 않는 코스") + void getSpotDetails_fail_courseNotFound() { + // given + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.empty()); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> spotService.getSpotDetails(COURSE_ID)); + assertThat(exception.getResponseCode()).isEqualTo(COURSE_NOT_FOUND); + } + + // 헬퍼 메서드 + private Course createMockCourse(Long courseId) { + Course course = Course.builder().build(); + ReflectionTestUtils.setField(course, "id", courseId); + return course; + } + + 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; + } +} From 8bc5adda78556e0a7a639365aa349f8b3309650c Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Mon, 11 Aug 2025 22:13:17 +0900 Subject: [PATCH 22/45] =?UTF-8?q?[SCRUM-228]=20FEAT:=20=EC=BD=94=EC=8A=A4?= =?UTF-8?q?=20=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90=20?= =?UTF-8?q?=EC=A6=90=EA=B8=B8=EA=B1=B0=EB=A6=AC=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코스 요약 조회 API에 즐길거리 정보를 추가했습니다. 즐길거리 정보는 랜덤으로 3개 응답하며, 이름, 설명, 이미지를 포함합니다. 관련 테스트 코드에도 즐길거리 정보 검증을 추가했습니다. --- .../domain/course/dto/CourseSummaryDto.java | 11 +++++-- .../domain/course/service/CourseService.java | 9 ++++-- .../spot/repository/SpotRepository.java | 32 +++++++++++++++++++ .../course/service/CourseServiceTest.java | 29 +++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java index 1530d00..9638507 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java +++ b/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java @@ -2,19 +2,24 @@ import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.review.dto.ReviewInfoListDto; +import com.server.running_handai.domain.spot.dto.SpotInfoDto; + +import java.util.List; public record CourseSummaryDto( double distance, int duration, double maxElevation, - ReviewInfoListDto reviewInfoListDto //TODO 즐길거리 dto 추가 + ReviewInfoListDto reviewInfoListDto, + List spots ) { - public static CourseSummaryDto from(Course course, ReviewInfoListDto reviewInfoListDto) { + public static CourseSummaryDto from(Course course, ReviewInfoListDto reviewInfoListDto, List spotInfoDtos) { return new CourseSummaryDto( course.getDistance(), course.getDuration(), course.getMaxElevation(), - reviewInfoListDto + reviewInfoListDto, + spotInfoDtos ); } } 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..fcedb96 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 @@ -21,6 +21,8 @@ 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 +52,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; @@ -214,8 +216,9 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { double averageStars = reviewService.calculateAverageStars(courseId); ReviewInfoListDto reviewInfoListDto = ReviewInfoListDto.from(averageStars, reviewInfoDtos); - // TODO 즐길거리 조회 + // 즐길거리 조회 + List spotInfoDtos = spotRepository.findRandom3ByCourseId(course.getId()); - return CourseSummaryDto.from(course, reviewInfoListDto); + return CourseSummaryDto.from(course, reviewInfoListDto, spotInfoDtos); } } 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 index 9b03922..9bcba5c 100644 --- 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 @@ -1,11 +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 findByCourseId(@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/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..40c1519 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 @@ -37,6 +37,10 @@ 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.domain.spot.service.SpotService; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import java.time.LocalDateTime; @@ -81,6 +85,9 @@ class CourseServiceTest { @Mock private ReviewRepository reviewRepository; + @Mock + private SpotRepository spotRepository; + @Mock private ReviewService reviewService; @@ -439,6 +446,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 @@ -466,10 +479,21 @@ void getCourseSummary_success(Long memberId, boolean 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(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); @@ -483,10 +507,15 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { 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.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(reviewService).convertToReviewInfoDtos(reviews, memberId); + verify(spotRepository).findRandom3ByCourseId(courseId); } @Test From 597b99e0494c028da2ab9f299b2d30a805dc505e Mon Sep 17 00:00:00 2001 From: ssggii Date: Wed, 13 Aug 2025 01:10:38 +0900 Subject: [PATCH 23/45] =?UTF-8?q?[SCRUM-228]=20=EC=BD=94=EC=8A=A4=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=20API=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코스 요약 조회 API에서 전체 리뷰 개수 조회 로직을 수정했습니다. --- .../domain/course/dto/CourseSummaryDto.java | 13 ++++++++++--- .../domain/course/service/CourseService.java | 8 ++++---- .../domain/review/dto/ReviewInfoListDto.java | 4 ++-- .../review/repository/ReviewRepository.java | 5 +++++ .../domain/review/service/ReviewService.java | 2 +- .../course/service/CourseServiceTest.java | 17 ++++++++--------- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java index 9638507..c2d9858 100644 --- a/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java +++ b/src/main/java/com/server/running_handai/domain/course/dto/CourseSummaryDto.java @@ -1,6 +1,7 @@ package com.server.running_handai.domain.course.dto; import com.server.running_handai.domain.course.entity.Course; +import com.server.running_handai.domain.review.dto.ReviewInfoDto; import com.server.running_handai.domain.review.dto.ReviewInfoListDto; import com.server.running_handai.domain.spot.dto.SpotInfoDto; @@ -10,15 +11,21 @@ public record CourseSummaryDto( double distance, int duration, double maxElevation, - ReviewInfoListDto reviewInfoListDto, + int reviewCount, + double starAverage, + List reviews, List spots ) { - public static CourseSummaryDto from(Course course, ReviewInfoListDto reviewInfoListDto, List spotInfoDtos) { + public static CourseSummaryDto from(Course course, int reviewCount, double starAverage, + List reviewInfoDtos, List spotInfoDtos) { + return new CourseSummaryDto( course.getDistance(), course.getDuration(), course.getMaxElevation(), - reviewInfoListDto, + reviewCount, + starAverage, + reviewInfoDtos, spotInfoDtos ); } 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 fcedb96..ae305d6 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 @@ -212,13 +212,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.findRandom2ByCourseId(courseId), memberId); // TODO 최신순으로 수정 + int reviewCount = (int) reviewRepository.countByCourseId(courseId); // 리뷰 전체 개수 + double starAverage = reviewService.calculateAverageStars(courseId); // 리뷰 전체 평점 // 즐길거리 조회 List spotInfoDtos = spotRepository.findRandom3ByCourseId(course.getId()); - return CourseSummaryDto.from(course, reviewInfoListDto, spotInfoDtos); + return CourseSummaryDto.from(course, reviewCount, starAverage, reviewInfoDtos, spotInfoDtos); } } 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..7811e4d 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 @@ -29,4 +29,9 @@ public interface ReviewRepository extends JpaRepository { */ @Query("SELECT AVG(r.stars) FROM Review r WHERE r.course.id = :courseId") Double findAverageStarsByCourseId(@Param("courseId") Long courseId); + + /** + * courseId로 조회한 리뷰의 전체 개수 조회 + */ + long countByCourseId(Long courseId); } 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..250d80a 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 @@ -73,7 +73,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); } /** 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 40c1519..bc650f8 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 @@ -40,7 +40,6 @@ 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.domain.spot.service.SpotService; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import java.time.LocalDateTime; @@ -471,9 +470,6 @@ 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) @@ -491,8 +487,9 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { given(courseRepository.findById(courseId)).willReturn(Optional.of(course)); given(reviewRepository.findRandom2ByCourseId(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 @@ -503,10 +500,10 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { assertThat(result.distance()).isEqualTo(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.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()); @@ -514,6 +511,8 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { verify(courseRepository).findById(courseId); verify(reviewRepository).findRandom2ByCourseId(courseId); + verify(reviewRepository).countByCourseId(courseId); + verify(reviewService).calculateAverageStars(courseId); verify(reviewService).convertToReviewInfoDtos(reviews, memberId); verify(spotRepository).findRandom3ByCourseId(courseId); } From 5a5ecc996a1111b14171d270f21e349a0e632e92 Mon Sep 17 00:00:00 2001 From: ssggii Date: Wed, 13 Aug 2025 01:25:36 +0900 Subject: [PATCH 24/45] =?UTF-8?q?[SCRUM-228]=20FIX:=20=EC=BD=94=EC=8A=A4?= =?UTF-8?q?=20=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=88=9C=EC=84=9C=20=EC=88=98=EC=A0=95=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코스 요약 조회 시 최신순 리뷰 2개를 반환하도록 수정했습니다. --- .../running_handai/domain/course/service/CourseService.java | 3 +-- .../domain/review/repository/ReviewRepository.java | 6 +++--- .../domain/course/service/CourseServiceTest.java | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) 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 ae305d6..58d6f99 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 @@ -18,7 +18,6 @@ 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; @@ -212,7 +211,7 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { // 리뷰 조회 List reviewInfoDtos = reviewService.convertToReviewInfoDtos( - reviewRepository.findRandom2ByCourseId(courseId), memberId); // TODO 최신순으로 수정 + reviewRepository.findRecent2ByCourseId(courseId), memberId); int reviewCount = (int) reviewRepository.countByCourseId(courseId); // 리뷰 전체 개수 double starAverage = reviewService.calculateAverageStars(courseId); // 리뷰 전체 평점 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 7811e4d..2836b16 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,10 +19,10 @@ 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) 평균을 계산 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 bc650f8..8b8bfe9 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 @@ -486,7 +486,7 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { ); 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); @@ -510,7 +510,7 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { 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); From b39b4522c90e78e5daf08f1574e8c9673aadf6a3 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Wed, 13 Aug 2025 04:19:22 +0900 Subject: [PATCH 25/45] =?UTF-8?q?[SCRUM-239]=20FEAT:=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=A4=91=EB=B3=B5=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닉네임 중복 조회 API를 구현했습니다. 중복이지 않으면 true, 중복이면 false를 반환하고, 유효성 검증도 함께 수행합니다. --- .../member/controller/MemberController.java | 36 +++++++++++-- .../member/repository/MemberRepository.java | 13 ++++- .../domain/member/service/MemberService.java | 50 ++++++++++++++++++- .../global/response/ResponseCode.java | 6 +++ 4 files changed, 99 insertions(+), 6 deletions(-) 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..7ae6e81 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,8 @@ 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.global.response.CommonResponse; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.domain.member.dto.TokenRequestDto; @@ -10,9 +13,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; 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.*; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/members") @@ -21,15 +27,37 @@ 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"), + @ApiResponse(responseCode = "400", description = "실패 (한글, 영문, 숫자 외의 문자가 존재) - INVALID_NICKNAME_FORMAT"), + @ApiResponse(responseCode = "400", description = "실패 (현재 사용 중인 닉네임과 동일) - SAME_AS_CURRENT_NICKNAME"), + }) + @GetMapping("/nickname") + public ResponseEntity> checkNicknameDuplicate( + @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)); + } } 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..c4cdfa0 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 @@ -6,7 +6,18 @@ import java.util.Optional; 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..e38029c 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,7 @@ 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.global.jwt.JwtProvider; import com.server.running_handai.global.oauth.userInfo.OAuth2UserInfo; import com.server.running_handai.global.response.ResponseCode; @@ -10,7 +12,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; @@ -153,4 +155,50 @@ 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); + } + + /** + * 닉네임 유효성을 검증합니다. + * 테스트를 위해 가시성을 완화했습니다. (private -> package-private) + * + * @param newNickname 검증할 닉네임 + * @param currentNickname 사용자의 현재 닉네임 + * @return 사용 가능하면 true, 사용 불가하면 false. + */ + boolean isNicknameValid(String newNickname, String currentNickname) { + // 닉네임 글자수는 2글자부터 최대 10글자까지 + if (newNickname.length() < 2 || newNickname.length() > 10) { + throw new BusinessException(ResponseCode.INVALID_NICKNAME_LENGTH); + } + + // 닉네임은 한글, 숫자, 영문만 입력할 수 있음 + String pattern = "^[가-힣a-zA-Z0-9]+$"; + if (!newNickname.matches(pattern)) { + throw new BusinessException(ResponseCode.INVALID_NICKNAME_FORMAT); + } + + // 이미 자신이 사용 중인 닉네임이어서는 안됨 + if (currentNickname.equals(newNickname)) { + throw new BusinessException(ResponseCode.SAME_AS_CURRENT_NICKNAME); + } + + return !memberRepository.existsByNickname(newNickname); + } } \ No newline at end of file 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..ad0a4e7 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 @@ -28,6 +28,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,6 +49,9 @@ 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, "잘못된 인자 값입니다."), From af40ecdb055510665ed2bd0f8b3f02bf15c21fd6 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Wed, 13 Aug 2025 04:25:00 +0900 Subject: [PATCH 26/45] =?UTF-8?q?[SCRUM-239]=20FEAT:=20=EB=82=B4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 정보 수정 API를 구현했습니다. 현재는 닉네임만 수정 가능하며, 닉네임 수정 시 유효성 검증 및 중복 확인을 수행합니다. --- .../member/controller/MemberController.java | 26 ++++++++++++++++-- .../member/dto/MemberUpdateRequestDto.java | 4 +++ .../member/dto/MemberUpdateResponseDto.java | 10 +++++++ .../domain/member/entity/Member.java | 3 +++ .../domain/member/service/MemberService.java | 27 ++++++++++++++++++- 5 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateRequestDto.java create mode 100644 src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateResponseDto.java 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 7ae6e81..1ebec4d 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 @@ -40,7 +40,7 @@ public ResponseEntity> createToken(@RequestBody return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, tokenResponseDto)); } - @Operation(summary = "닉네임 중복 조회", + @Operation(summary = "닉네임 중복 여부 조회", description = "사용자가 수정하려는 닉네임이 중복이 아닌 경우 true, 중복인 경우 false를 응답합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), @@ -56,8 +56,30 @@ public ResponseEntity> checkNicknameDuplicate( @AuthenticationPrincipal CustomOAuth2User customOAuth2User ) { Long memberId = customOAuth2User.getMember().getId(); - log.info("[닉네임 유효성 조회] memberId: {} nickname: {}", memberId, nickname); + 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"), + @ApiResponse(responseCode = "400", description = "실패 (한글, 영문, 숫자 외의 문자가 존재) - INVALID_NICKNAME_FORMAT"), + @ApiResponse(responseCode = "400", description = "실패 (현재 사용 중인 닉네임과 동일) - SAME_AS_CURRENT_NICKNAME"), + @ApiResponse(responseCode = "409", description = "실패 (중복된 닉네임) - DUPLICATE_NICKNAME"), + }) + @PatchMapping("/me") + public ResponseEntity> updateMemberInfo( + @RequestBody 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)); + } } 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..7796844 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/member/dto/MemberUpdateRequestDto.java @@ -0,0 +1,4 @@ +package com.server.running_handai.domain.member.dto; + +public record MemberUpdateRequestDto (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/service/MemberService.java b/src/main/java/com/server/running_handai/domain/member/service/MemberService.java index e38029c..589f884 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 @@ -158,7 +158,7 @@ private String generateRandomNickname() { /** * 닉네임 중복 여부를 조회합니다. - * 유효성 검증도 함께 수행합니다. + * 닉네임 유효성 검증도 함께 수행합니다. * * @param memberId 사용자 Id * @param nickname 검증할 닉네임 @@ -174,6 +174,31 @@ public Boolean checkNicknameDuplicate(Long memberId, String nickname) { 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) From 103c584b844c2233de17050621d4af80f5d0e3a2 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Wed, 13 Aug 2025 04:30:00 +0900 Subject: [PATCH 27/45] =?UTF-8?q?[SCRUM-239]=20TEST:=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 정보 수정 API와 닉네임 중복 여부 조회 API에서 공통되게 사용되는 닉네임 유효성 검증 메서드에 대해 테스트 코드를 구현하고 테스트를 진행했습니다. (실패 3개) --- .../member/service/MemberServiceTest.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/test/java/com/server/running_handai/domain/member/service/MemberServiceTest.java 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..fe6d2a7 --- /dev/null +++ b/src/test/java/com/server/running_handai/domain/member/service/MemberServiceTest.java @@ -0,0 +1,73 @@ +package com.server.running_handai.domain.member.service; + +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 static com.server.running_handai.global.response.ResponseCode.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@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); + } + } + +} From 762802bac481be9e90e50500c3af50e0b9453b8a Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Wed, 13 Aug 2025 05:19:17 +0900 Subject: [PATCH 28/45] =?UTF-8?q?[SCRUM-239]=20TEST:=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=A4=91=EB=B3=B5=20=EC=97=AC=EB=B6=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닉네임 중복 여부 조회 API의 서비스 메서드들에 대하여 테스트 코드를 구현하고 테스트를 진행했습니다. (성공 2개, 실패 1개) --- .../member/service/MemberServiceTest.java | 97 +++++++++++++++++-- 1 file changed, 90 insertions(+), 7 deletions(-) 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 index fe6d2a7..a1de0df 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -12,10 +15,16 @@ 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) @@ -27,14 +36,15 @@ class MemberServiceTest { private MemberRepository memberRepository; @Nested - @DisplayName("닉네임 유효성 검증 메서드 테스트") + @DisplayName("닉네임 유효성 검증 테스트") class NicknameValidationTest { + /** - * [닉네임 유효성 검증 메서드] 실패 + * [닉네임 유효성 검증] 실패 * 1. 현재 자신의 닉네임과 동일한 경우 */ @Test - @DisplayName("닉네임 유효성 검증 메서드 - 본인 닉네임과 동일") + @DisplayName("닉네임 유효성 검증 실패 - 본인 닉네임과 동일") void isNicknameValid_fail_sameAsCurrentNickname() { // given String currentNickname = "current"; @@ -46,28 +56,101 @@ void isNicknameValid_fail_sameAsCurrentNickname() { } /** - * [닉네임 유효성 검증 메서드] 실패 + * [닉네임 유효성 검증] 실패 * 2. 글자수가 안맞는 경우 (2글자 ~ 10글자) */ @ParameterizedTest @ValueSource(strings = {"a", "verylongnickname123"}) - @DisplayName("닉네임 유효성 검증 메서드 - 글자수가 안맞음") + @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("닉네임 유효성 검증 메서드 - 한글, 영문, 숫자 외의 문자 존재") + @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); + } + + /** + * [닉네임 중복 여부 조회] 실패 + * 3. 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); + } + } } From 9591c4f7abd2d815c5c62ae25d6efde2f55153b7 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Wed, 13 Aug 2025 05:21:14 +0900 Subject: [PATCH 29/45] =?UTF-8?q?[SCRUM-239]=20TEST:=20=EB=82=B4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20API=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 정보 수정 API의 서비스 메서드들에 대하여 테스트 코드를 구현하고 테스트를 진행했습니다. (성공 1개, 실패 2개) --- .../member/service/MemberServiceTest.java | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) 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 index a1de0df..3924cee 100644 --- 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 @@ -140,7 +140,7 @@ void checkNicknameDuplicate_success_duplicateNickname() { /** * [닉네임 중복 여부 조회] 실패 - * 3. Member가 존재하지 않을 경우 + * 1. Member가 존재하지 않을 경우 */ @Test @DisplayName("닉네임 중복 확인 실패 - 찾을 수 없는 사용자") @@ -153,4 +153,75 @@ void checkNicknameDuplicate_fail_memberNotFound() { 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); + } + } } From 98309de5877d018621546fada26eb5afd2f948ac Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Wed, 13 Aug 2025 05:40:51 +0900 Subject: [PATCH 30/45] =?UTF-8?q?[SCRUM-239]=20REFACTOR:=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=95=EA=B7=9C=EC=8B=9D=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=83=81=EC=88=98=ED=99=94=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 16 ++++++++++------ .../domain/member/service/MemberService.java | 14 +++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) 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 1ebec4d..45c852c 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 @@ -46,9 +46,11 @@ public ResponseEntity> createToken(@RequestBody @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"), - @ApiResponse(responseCode = "400", description = "실패 (한글, 영문, 숫자 외의 문자가 존재) - INVALID_NICKNAME_FORMAT"), - @ApiResponse(responseCode = "400", description = "실패 (현재 사용 중인 닉네임과 동일) - SAME_AS_CURRENT_NICKNAME"), + @ApiResponse(responseCode = "400", description = + "실패 (유효성 검증):
" + + "• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH
" + + "• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT
" + + "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME"), }) @GetMapping("/nickname") public ResponseEntity> checkNicknameDuplicate( @@ -67,9 +69,11 @@ public ResponseEntity> checkNicknameDuplicate( @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"), - @ApiResponse(responseCode = "400", description = "실패 (한글, 영문, 숫자 외의 문자가 존재) - INVALID_NICKNAME_FORMAT"), - @ApiResponse(responseCode = "400", description = "실패 (현재 사용 중인 닉네임과 동일) - SAME_AS_CURRENT_NICKNAME"), + @ApiResponse(responseCode = "400", description = + "실패 (유효성 검증):
" + + "• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH
" + + "• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT
" + + "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME"), @ApiResponse(responseCode = "409", description = "실패 (중복된 닉네임) - DUPLICATE_NICKNAME"), }) @PatchMapping("/me") 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 589f884..261739f 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 @@ -27,6 +27,7 @@ public class MemberService { private final JwtProvider jwtProvider; public static final int NICKNAME_NUMBER = 10; + private static final String NICKNAME_PATTERN = "^[ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z0-9]{2,10}$"; /** * OAuth2 사용자 정보를 기반으로 회원을 생성하거나 기존 회원을 조회합니다. @@ -208,22 +209,21 @@ public MemberUpdateResponseDto updateMemberInfo(Long memberId, MemberUpdateReque * @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() < 2 || newNickname.length() > 10) { throw new BusinessException(ResponseCode.INVALID_NICKNAME_LENGTH); } // 닉네임은 한글, 숫자, 영문만 입력할 수 있음 - String pattern = "^[가-힣a-zA-Z0-9]+$"; - if (!newNickname.matches(pattern)) { + if (!newNickname.matches(NICKNAME_PATTERN)) { throw new BusinessException(ResponseCode.INVALID_NICKNAME_FORMAT); } - // 이미 자신이 사용 중인 닉네임이어서는 안됨 - if (currentNickname.equals(newNickname)) { - throw new BusinessException(ResponseCode.SAME_AS_CURRENT_NICKNAME); - } - return !memberRepository.existsByNickname(newNickname); } } \ No newline at end of file From f14b50357adab8a4e62af15da81ec37b5b7ce0e5 Mon Sep 17 00:00:00 2001 From: ssggii Date: Wed, 13 Aug 2025 15:42:37 +0900 Subject: [PATCH 31/45] =?UTF-8?q?[SCRUM-235]=20CHORE:=20=EB=82=B4=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#9?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 리뷰 조회 API의 엔드포인트를 /api/me로 시작하도록 수정했습니다. --- .../domain/review/controller/ReviewController.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 77bf32f..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 @@ -110,11 +110,12 @@ public ResponseEntity> deleteReview( @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "200", description = "성공 (리뷰 없음)") }) - @GetMapping("/api/me/reviews") + @GetMapping("/api/members/me/reviews") public ResponseEntity>> getMyReviews( @AuthenticationPrincipal CustomOAuth2User customOAuth2User ) { - List responseData = reviewService.getMyReviews(customOAuth2User.getMember().getId()); + Long memberId = customOAuth2User.getMember().getId(); + List responseData = reviewService.getMyReviews(memberId); if (responseData.isEmpty()) { return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS_EMPTY_REVIEWS, responseData)); From c61b165dfcd71dc86d8500436667ca9c1f3162ad Mon Sep 17 00:00:00 2001 From: ssggii Date: Wed, 13 Aug 2025 16:02:30 +0900 Subject: [PATCH 32/45] =?UTF-8?q?[SCRUM-235]=20FIX:=20=EB=82=B4=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A0=95=EB=A0=AC=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 리뷰 조회 시 리뷰들이 최신순으로 반환되도록 정렬 조건을 추가했습니다. --- .../domain/review/repository/ReviewRepository.java | 3 ++- src/main/resources/application.yml | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 10a1262..3bb83ca 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 @@ -36,6 +36,7 @@ public interface ReviewRepository extends JpaRepository { @Query("SELECT r FROM Review r " + "LEFT JOIN FETCH r.course c " + "LEFT JOIN FETCH c.courseImage ci " + - "WHERE r.writer.id = :memberId") + "WHERE r.writer.id = :memberId " + + "ORDER BY r.createdAt DESC") List findReviewsWithDetailsByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d34d0f1..0984ea2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -91,6 +91,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 From 0e9f5814c05086a67c8cb27ba0e7c2566d3038fa Mon Sep 17 00:00:00 2001 From: ssggii Date: Wed, 13 Aug 2025 16:09:41 +0900 Subject: [PATCH 33/45] =?UTF-8?q?[SCRUM-236]=20CHORE:=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=ED=95=9C=20=EC=BD=94=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bookmark/controller/BookmarkController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 44f09e7..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 @@ -76,7 +76,7 @@ public ResponseEntity> deleteBookmark( @ApiResponse(responseCode = "400", description = "실패 (요청 파라미터 오류)"), @ApiResponse(responseCode = "401", description = "실패 (인증 실패)") }) - @GetMapping("/api/me/courses/bookmark") + @GetMapping("/api/members/me/courses/bookmarks") public ResponseEntity> getBookmarkedCourses( @Parameter(description = "지역 조건 (전체 보기인 경우 null)") @RequestParam(required = false) Area area, From 9c50650faaa5f46d02a9c8731e706a4192666cbf Mon Sep 17 00:00:00 2001 From: ssggii Date: Wed, 13 Aug 2025 16:24:56 +0900 Subject: [PATCH 34/45] =?UTF-8?q?[SCRUM-236]=20FIX:=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=ED=95=9C=20=EC=BD=94=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=BD=94=EC=8A=A4=20=EC=A0=95=EB=A0=AC=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 북마크한 코스 조회 시, 북마크한 시점을 기준으로 응답을 최신순으로 정렬하도록 수정했습니다. --- .../domain/bookmark/repository/BookmarkRepository.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 158f075..724a8cd 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 @@ -49,7 +49,8 @@ public interface BookmarkRepository extends JpaRepository { + "FROM Bookmark b " + "LEFT JOIN b.course c " + "LEFT JOIN c.courseImage ci " - + "WHERE b.member.id = :memberId" + + "WHERE b.member.id = :memberId " + + "ORDER BY b.createdAt DESC " ) List findBookmarkedCoursesByMemberId(Long memberId); @@ -67,7 +68,8 @@ public interface BookmarkRepository extends JpaRepository { + "LEFT JOIN b.course c " + "LEFT JOIN c.courseImage ci " + "WHERE b.member.id = :memberId " - + "AND c.area = :area" + + "AND c.area = :area " + + "ORDER BY b.createdAt DESC " ) List findBookmarkedCoursesByMemberIdAndArea(Long memberId, Area area); } From 4d28cd8338fc587f528978ecfc3e59e17e65ac1a Mon Sep 17 00:00:00 2001 From: ssggii Date: Wed, 13 Aug 2025 21:47:29 +0900 Subject: [PATCH 35/45] =?UTF-8?q?[SCRUM-240]=20RENAME:=20BookmarkCountDto,?= =?UTF-8?q?=20BookmarkInfoDto=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BookmarkCountDto와 BookmarkInfoDto의 상위 패키지를 domain/bookmark로 변경했습니다. --- .../domain/{course => bookmark}/dto/BookmarkCountDto.java | 2 +- .../domain/{course => bookmark}/dto/BookmarkInfoDto.java | 2 +- .../domain/bookmark/repository/BookmarkRepository.java | 2 +- .../running_handai/domain/course/dto/CourseDetailDto.java | 1 + .../running_handai/domain/course/service/CourseService.java | 4 ++-- .../domain/course/service/CourseServiceTest.java | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) rename src/main/java/com/server/running_handai/domain/{course => bookmark}/dto/BookmarkCountDto.java (56%) rename src/main/java/com/server/running_handai/domain/{course => bookmark}/dto/BookmarkInfoDto.java (57%) 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/repository/BookmarkRepository.java b/src/main/java/com/server/running_handai/domain/bookmark/repository/BookmarkRepository.java index 724a8cd..e7bb5fc 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 @@ -2,7 +2,7 @@ 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; 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..8040c55 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; 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..6752825 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; 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..14edd5a 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; From a1d4598553de3eff7a5222e3d39ba913a1341429 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 14 Aug 2025 23:42:35 +0900 Subject: [PATCH 36/45] =?UTF-8?q?[SCRUM-228]=20RENAME:=20JPA=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch join을 사용하는 메서드임을 명시하고, 기본 JPA 메서드와 구분하기 위해 findByCourseId에서 findByCourseIdWithSpotImage로 JPA 메서드명을 변경했습니다. --- .../running_handai/domain/spot/repository/SpotRepository.java | 2 +- .../server/running_handai/domain/spot/service/SpotService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 9bcba5c..d4cea41 100644 --- 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 @@ -23,7 +23,7 @@ public interface SpotRepository extends JpaRepository { "LEFT JOIN FETCH s.spotImage " + "JOIN CourseSpot cs ON cs.spot = s " + "WHERE cs.course.id = :courseId") - List findByCourseId(@Param("courseId") Long courseId); + List findByCourseIdWithSpotImage(@Param("courseId") Long courseId); /** * CourseId와 일치하는 Spot을 SpotImage와 함께 랜덤으로 3개 가져옵니다. 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 index 926ff71..391818c 100644 --- 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 @@ -31,7 +31,7 @@ public class SpotService { */ public SpotDetailDto getSpotDetails(Long courseId) { Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); - List spots = spotRepository.findByCourseId(course.getId()); + List spots = spotRepository.findByCourseIdWithSpotImage(course.getId()); List spotInfoDtos = spots.stream() .map(spot -> new SpotInfoDto( From e595ebfc57efdca54eda99f7f606092b9d9c758e Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 14 Aug 2025 23:58:58 +0900 Subject: [PATCH 37/45] =?UTF-8?q?[SCRUM-228]=20REFACTOR:=20Course=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSpotDetails에서는 Course 객체가 필요하지 않고 Course 존재 여부만 확인하고 있으므로 Course 존재 여부를 확인할 때 findById가 아닌 existsById를 사용하도록 변경했습니다. --- .../domain/spot/service/SpotService.java | 6 +- .../domain/spot/service/SpotServiceTest.java | 198 +++++++++--------- 2 files changed, 99 insertions(+), 105 deletions(-) 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 index 391818c..eedcfc1 100644 --- 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 @@ -30,8 +30,10 @@ public class SpotService { * @return 조회된 즐길거리 목록 DTO */ public SpotDetailDto getSpotDetails(Long courseId) { - Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); - List spots = spotRepository.findByCourseIdWithSpotImage(course.getId()); + if (!courseRepository.existsById(courseId)) { + throw new BusinessException(ResponseCode.COURSE_NOT_FOUND); + } + List spots = spotRepository.findByCourseIdWithSpotImage(courseId); List spotInfoDtos = spots.stream() .map(spot -> new SpotInfoDto( 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 index 028a90a..bfdea37 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -30,7 +31,7 @@ @ActiveProfiles("test") @ExtendWith(MockitoExtension.class) -public class SpotServiceTest { +class SpotServiceTest { @InjectMocks private SpotService spotService; @@ -41,108 +42,99 @@ public class SpotServiceTest { private SpotRepository spotRepository; private static final Long COURSE_ID = 1L; - private Course course; - @BeforeEach - void setUp() { - course = createMockCourse(COURSE_ID); - } - - /** - * [즐길거리 전체 조회] 성공 - * 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.findById(COURSE_ID)).willReturn(Optional.of(course)); - given(spotRepository.findByCourseId(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).findById(COURSE_ID); - verify(spotRepository).findByCourseId(COURSE_ID); - } - - /** - * [즐길거리 전체 조회] 성공 - * 2. Course에 해당되는 Spot이 존재하지 않는 경우 - */ - @Test - @DisplayName("즐길거리 전체 조회 - Spot이 존재하지 않음") - void getSpotDetails_success_noSpot() { - // given - // Spot이 존재하지 않으면 빈 리스트로 응답해야 함 - given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); - given(spotRepository.findByCourseId(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).findById(COURSE_ID); - verify(spotRepository).findByCourseId(course.getId()); - } - - /** - * [즐길거리 전체 조회] 실패 - * 1. Course가 존재하지 않을 경우 - */ - @Test - @DisplayName("즐길거리 전체 조회 - 존재하지 않는 코스") - void getSpotDetails_fail_courseNotFound() { - // given - given(courseRepository.findById(COURSE_ID)).willReturn(Optional.empty()); - - // when, then - BusinessException exception = assertThrows(BusinessException.class, () -> spotService.getSpotDetails(COURSE_ID)); - assertThat(exception.getResponseCode()).isEqualTo(COURSE_NOT_FOUND); - } - - // 헬퍼 메서드 - private Course createMockCourse(Long courseId) { - Course course = Course.builder().build(); - ReflectionTestUtils.setField(course, "id", courseId); - return course; - } - - 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; + @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); + } } } From 9c44964ec914a46162bb7a1f6499cb4ee0440611 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Fri, 15 Aug 2025 00:27:07 +0900 Subject: [PATCH 38/45] =?UTF-8?q?[SCRUM-228]=20REFACTOR:=20DTO=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=EB=B6=80=ED=84=B0=20=EB=B6=84=EB=A6=AC=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 가독성을 위해 SpotInfoDto, SpotDetailDto 생성 로직을 서비스로부터 분리했습니다. --- .../running_handai/domain/spot/dto/SpotDetailDto.java | 7 +++++++ .../running_handai/domain/spot/dto/SpotInfoDto.java | 10 ++++++++++ .../domain/spot/service/SpotService.java | 9 ++------- src/main/resources/application.yml | 1 + 4 files changed, 20 insertions(+), 7 deletions(-) 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 index 90aaef3..f81a2d8 100644 --- 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 @@ -7,4 +7,11 @@ public record SpotDetailDto ( 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 index d4c6fdb..621ee55 100644 --- 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 @@ -1,9 +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/service/SpotService.java b/src/main/java/com/server/running_handai/domain/spot/service/SpotService.java index eedcfc1..9898c83 100644 --- 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 @@ -36,14 +36,9 @@ public SpotDetailDto getSpotDetails(Long courseId) { List spots = spotRepository.findByCourseIdWithSpotImage(courseId); List spotInfoDtos = spots.stream() - .map(spot -> new SpotInfoDto( - spot.getId(), - spot.getName(), - spot.getDescription(), - spot.getSpotImage() != null ? spot.getSpotImage().getImgUrl() : null - )) + .map(SpotInfoDto::from) .toList(); - return new SpotDetailDto(courseId, spots.size(), spotInfoDtos); + return SpotDetailDto.from(courseId, spotInfoDtos); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d22e89..930d702 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -134,3 +134,4 @@ swagger: server: local: http://localhost:8080 prod: https://api.runninghandai.com + From e38430d9329b80e4187cbd4900324c80412be0d9 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Fri, 15 Aug 2025 00:55:21 +0900 Subject: [PATCH 39/45] =?UTF-8?q?[SCRUM-239]=20REFACTOR:=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=B5=9C=EC=86=8C,=20=EC=B5=9C=EB=8C=80?= =?UTF-8?q?=20=EA=B8=B8=EC=9D=B4=20=EC=83=81=EC=88=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닉네임 최소, 최대 길이를 상수 처리함으로써 숫자에 명확한 의미를 부여했습니다. --- .../domain/member/service/MemberService.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 261739f..5f7728a 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 @@ -26,7 +26,8 @@ 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}$"; /** @@ -133,7 +134,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) @@ -215,7 +216,7 @@ boolean isNicknameValid(String newNickname, String currentNickname) { } // 닉네임 글자수는 2글자부터 최대 10글자까지 - if (newNickname.length() < 2 || newNickname.length() > 10) { + if (newNickname.length() < NICKNAME_MIN_LENGTH || newNickname.length() > NICKNAME_MAX_LENGTH) { throw new BusinessException(ResponseCode.INVALID_NICKNAME_LENGTH); } From d1d8658fdd47f6a244f46161b5ace9ca36924d22 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Fri, 15 Aug 2025 01:53:26 +0900 Subject: [PATCH 40/45] =?UTF-8?q?[SCRUM-239]=20FEAT:=20@Valid,=20@Validate?= =?UTF-8?q?d=20=EC=9E=85=EB=A0=A5=EA=B0=92=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닉네임 중복 여부 조회 API와 내 정보 수정 API에서 @Validated, @Valid를 각각 사용하여 닉네임을 공백이나 Null로 요청하는 경우 @NotBlank를 사용해 검증하도록 수정했습니다. 추가적으로 @Validated 관련 Exception을 처리하기 위해 GlobalExceptionHandler에 ConstraintViolationException를 추가했습니다. --- .../member/controller/MemberController.java | 16 ++++++++++++---- .../member/dto/MemberUpdateRequestDto.java | 7 ++++++- .../global/response/ResponseCode.java | 2 +- .../exception/GlobalExceptionHandler.java | 12 ++++++++++-- 4 files changed, 29 insertions(+), 8 deletions(-) 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 45c852c..f1759f4 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 @@ -12,13 +12,17 @@ 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.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @Slf4j +@Validated @RestController @RequiredArgsConstructor @RequestMapping("/api/members") @@ -50,11 +54,13 @@ public ResponseEntity> createToken(@RequestBody "실패 (유효성 검증):
" + "• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH
" + "• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT
" + - "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME"), + "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME
" + + "• 공백이나 Null 값으로 호출 - INVALID_INPUT_VALUE" + ), }) @GetMapping("/nickname") public ResponseEntity> checkNicknameDuplicate( - @RequestParam("value") String nickname, + @NotBlank @RequestParam("value") String nickname, @AuthenticationPrincipal CustomOAuth2User customOAuth2User ) { Long memberId = customOAuth2User.getMember().getId(); @@ -73,12 +79,14 @@ public ResponseEntity> checkNicknameDuplicate( "실패 (유효성 검증):
" + "• 글자수가 2글자 미만, 10글자 초과 - INVALID_NICKNAME_LENGTH
" + "• 한글, 영문, 숫자 외의 문자가 존재 - INVALID_NICKNAME_FORMAT
" + - "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME"), + "• 현재 사용 중인 닉네임과 동일 - SAME_AS_CURRENT_NICKNAME
" + + "• 공백이나 Null 값으로 호출 - INVALID_INPUT_VALUE" + ), @ApiResponse(responseCode = "409", description = "실패 (중복된 닉네임) - DUPLICATE_NICKNAME"), }) @PatchMapping("/me") public ResponseEntity> updateMemberInfo( - @RequestBody MemberUpdateRequestDto memberUpdateRequestDto, + @RequestBody @Valid MemberUpdateRequestDto memberUpdateRequestDto, @AuthenticationPrincipal CustomOAuth2User customOAuth2User ) { Long memberId = customOAuth2User.getMember().getId(); 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 index 7796844..a78e9f3 100644 --- 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 @@ -1,4 +1,9 @@ package com.server.running_handai.domain.member.dto; -public record MemberUpdateRequestDto (String nickname) { +import jakarta.validation.constraints.NotBlank; + +public record MemberUpdateRequestDto ( + @NotBlank + String nickname +) { } 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 ad0a4e7..80246ad 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 @@ -55,11 +55,11 @@ public enum ResponseCode { /** 시스템 및 공통 예외용 에러 코드 */ // 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, "존재하지 않는 리소스입니다."), 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를 가지고 요청할 경우 From 710780ccbe9ccd2f4f5f33d61d22205357a0a87f Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Fri, 15 Aug 2025 16:58:39 +0900 Subject: [PATCH 41/45] =?UTF-8?q?FIX:=20AWS=20S3=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=81=EB=AC=B8=20=ED=8C=8C=EC=9D=BC=EB=AA=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5=EB=90=98=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 공유 시 한글 파일명으로 인한 URL 인코딩 불일치로 썸네일 이미지 Access Denied(403) 오류가 발생했습니다. 따라서 AWS S3에 파일 저장 시 한글을 제외한 영문 파일명만 허용되도록 수정했습니다. (영문, 숫자, 하이픈, 언더스코어만 허용) --- .../course/service/CourseDataService.java | 2 +- .../domain/course/service/FileService.java | 50 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) 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 cce2e68..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 @@ -448,7 +448,7 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) { Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); // 새 파일을 S3에 먼저 업로드 - String newImageUrl = fileService.uploadFile(courseImageFile, "course"); + String newImageUrl = fileService.uploadFile(courseImageFile, "image"); log.info("[코스 이미지 수정] S3 버킷에 이미지 업로드 완료: newImageUrl={}", newImageUrl); // 삭제할 기존 파일 URL을 임시 변수에 저장 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 35aee78..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 @@ -44,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) @@ -54,17 +56,18 @@ public FileService(S3Client s3Client) { */ public String uploadFile(MultipartFile multipartFile, String directory) { String originalFileName = multipartFile.getOriginalFilename(); + if (originalFileName == null || originalFileName.isBlank()) { - originalFileName = "no-name"; + log.warn("[S3 파일 업로드] 파일명을 찾을 수 없어 기본값 제공"); + originalFileName = "file"; } - String fileName = directory + "/" + UUID.randomUUID() + "_" + originalFileName; - String contentType = multipartFile.getContentType(); - if (contentType == null || contentType.isBlank()) { - contentType = guessContentType(originalFileName); - } + String contentType = guessContentType(originalFileName); validateFileType(originalFileName); + String newFileName = changeFileName(originalFileName); + String fileName = directory + "/" + UUID.randomUUID() + "_" + newFileName; + try { return uploadToS3(fileName, contentType, multipartFile.getInputStream(), multipartFile.getSize()); } catch (IOException e) { @@ -86,14 +89,18 @@ public String uploadFileByUrl(String fileUrl, String directory) { URL url = new URL(fileUrl); String path = url.getPath(); String originalFileName = path.substring(path.lastIndexOf('/') + 1); - if (originalFileName == null || originalFileName.isBlank()) { - originalFileName = "no-name"; + + if (originalFileName.isBlank()) { + log.warn("[S3 파일 업로드] 파일명을 찾을 수 없어 기본값 제공"); + originalFileName = "file"; } - String fileName = directory + "/" + UUID.randomUUID() + "_" + originalFileName; 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(); @@ -222,6 +229,31 @@ private void validateFileType(String fileName) { } } + /** + * 저장 시 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을 반환합니다. * From 19688299fcf267bae17d6c86d8316bbc778a6633 Mon Sep 17 00:00:00 2001 From: ssggii Date: Fri, 15 Aug 2025 20:14:33 +0900 Subject: [PATCH 42/45] =?UTF-8?q?[SCRUM-240]=20FEAT:=20=EB=82=B4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 정보 조회 API에서 회원의 닉네임과 이메일만 조회하도록 구현했습니다. 추후 북마크한 코스 정보와 생성한 코스 정보를 추가할 예정입니다. --- .../domain/bookmark/dto/MyBookmarkInfoDto.java | 11 +++++++++++ .../member/controller/MemberController.java | 17 +++++++++++++++++ .../domain/member/dto/MemberInfoDto.java | 12 ++++++++++++ .../member/repository/MemberRepository.java | 2 ++ .../domain/member/service/MemberService.java | 13 +++++++++++++ 5 files changed, 55 insertions(+) create mode 100644 src/main/java/com/server/running_handai/domain/bookmark/dto/MyBookmarkInfoDto.java create mode 100644 src/main/java/com/server/running_handai/domain/member/dto/MemberInfoDto.java 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/member/controller/MemberController.java b/src/main/java/com/server/running_handai/domain/member/controller/MemberController.java index 467aef2..2148418 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,7 @@ package com.server.running_handai.domain.member.controller; +import com.server.running_handai.domain.member.dto.MemberInfoDto; +import com.server.running_handai.global.oauth.CustomOAuth2User; 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; @@ -11,6 +13,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -32,4 +35,18 @@ public ResponseEntity> createToken(@RequestBody TokenResponseDto tokenResponseDto = memberService.createToken(tokenRequestDto); return ResponseEntity.ok(CommonResponse.success(ResponseCode.SUCCESS, tokenResponseDto)); } + + @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/repository/MemberRepository.java b/src/main/java/com/server/running_handai/domain/member/repository/MemberRepository.java index 3fd2cfd..b6cd06c 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,6 +4,8 @@ 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 { Optional findByProviderId(String providerId); 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..25a190a 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,6 @@ package com.server.running_handai.domain.member.service; +import com.server.running_handai.domain.member.dto.MemberInfoDto; 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; @@ -153,4 +154,16 @@ private String generateRandomNickname() { return nickname; } + + /** + * 회원 정보를 조회합니다. + * + * @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 From 2b8e5068cad2151fe0fb9f1a5d0b24492a10dabf Mon Sep 17 00:00:00 2001 From: ssggii Date: Fri, 15 Aug 2025 20:24:21 +0900 Subject: [PATCH 43/45] =?UTF-8?q?[SCRUM-240]=20CHORE:=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BookmarkCountDto의 패키지 경로가 변경되었는데 레포지토리 메서드 내에서 해당 패키지 경로를 수정하지 않아 발생한 빌드 에러를 해결하였습니다. --- .../domain/bookmark/repository/BookmarkRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e7bb5fc..b540b95 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 @@ -25,7 +25,7 @@ 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 " From 71a997335cd154340e6ad7ce7564f768e2093c51 Mon Sep 17 00:00:00 2001 From: ssggii Date: Fri, 15 Aug 2025 21:08:02 +0900 Subject: [PATCH 44/45] =?UTF-8?q?FIX:=20=EA=B8=B0=EC=A1=B4=20API=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9D=98=20=EC=BD=94=EC=8A=A4=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4,=20=EA=B3=A0=EB=8F=84=EA=B0=92=EC=9D=84=20=EC=9E=90?= =?UTF-8?q?=EC=97=B0=EC=88=98=20=ED=98=95=ED=83=9C=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코스 조회와 관련된 API 응답에 포함된 코스 길이와 고도값을 자연수 형태로 수정했습니다. --- .../domain/bookmark/dto/BookmarkedCourseInfoDto.java | 9 ++++++++- .../bookmark/repository/BookmarkRepository.java | 4 ++-- .../domain/course/dto/CourseDetailDto.java | 4 ++-- .../domain/course/dto/CourseInfoWithDetailsDto.java | 4 ++-- .../domain/course/dto/CourseSummaryDto.java | 8 ++++---- .../domain/review/dto/MyReviewInfoDto.java | 4 ++-- .../domain/course/service/CourseServiceTest.java | 12 ++++++------ .../domain/review/service/ReviewServiceTest.java | 2 +- 8 files changed, 27 insertions(+), 20 deletions(-) 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 index fd80ca6..8913fdb 100644 --- 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 @@ -17,7 +17,14 @@ public interface BookmarkedCourseInfoDto { long getBookmarkId(); long getCourseId(); String getThumbnailUrl(); - double getDistance(); + + @JsonIgnore + double getRawDistance(); + + default int getDistance() { + return (int) getRawDistance(); + } + int getDuration(); @JsonIgnore 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 b540b95..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 @@ -41,7 +41,7 @@ public interface BookmarkRepository extends JpaRepository { + "b.id AS bookmarkId, " + "c.id AS courseId, " + "ci.imgUrl AS thumbnailUrl, " - + "c.distance AS distance, " + + "c.distance AS rawDistance, " + "c.duration AS duration, " + "c.maxElevation AS rawMaxElevation, " + "true AS isBookmarked, " @@ -59,7 +59,7 @@ public interface BookmarkRepository extends JpaRepository { + "b.id AS bookmarkId, " + "c.id AS courseId, " + "ci.imgUrl AS thumbnailUrl, " - + "c.distance AS distance, " + + "c.distance AS rawDistance, " + "c.duration AS duration, " + "c.maxElevation AS rawMaxElevation, " + "true AS isBookmarked, " 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 8040c55..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 @@ -8,7 +8,7 @@ public record CourseDetailDto( Long courseId, String courseName, - double distance, + int distance, int duration, int minElevation, int maxElevation, @@ -25,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, @@ -20,9 +20,9 @@ public static CourseSummaryDto from(Course course, int reviewCount, double starA List reviewInfoDtos, List spotInfoDtos) { return new CourseSummaryDto( - course.getDistance(), + (int) course.getDistance(), course.getDuration(), - course.getMaxElevation(), + (int) course.getMaxElevation().doubleValue(), reviewCount, starAverage, reviewInfoDtos, 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 index 129edec..4d4c499 100644 --- 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 @@ -10,7 +10,7 @@ public record MyReviewInfoDto( String courseName, String thumbnailUrl, String area, - double distance, + int distance, int duration, int maxElevation, double stars, @@ -28,7 +28,7 @@ public static MyReviewInfoDto from(Review review) { course.getName(), (course.getCourseImage() != null) ? course.getCourseImage().getImgUrl() : null, course.getArea().name(), - course.getDistance(), + (int) course.getDistance(), course.getDuration(), (int) course.getMaxElevation().doubleValue(), review.getStars(), 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 4eb37e0..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 @@ -151,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"); @@ -206,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"); @@ -287,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()); @@ -324,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()); @@ -497,9 +497,9 @@ void getCourseSummary_success(Long memberId, boolean isMyReview) { // 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.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()); 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 6fca5d6..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 @@ -579,7 +579,7 @@ void getMyReview_success_hasReview() { assertThat(firstDto.courseName()).isEqualTo(course.getName()); assertThat(firstDto.thumbnailUrl()).isEqualTo(course.getCourseImage().getImgUrl()); assertThat(firstDto.area()).isEqualTo(course.getArea().name()); - assertThat(firstDto.distance()).isEqualTo(course.getDistance()); + assertThat(firstDto.distance()).isEqualTo((int) course.getDistance()); assertThat(firstDto.duration()).isEqualTo(course.getDuration()); assertThat(firstDto.maxElevation()).isEqualTo((int) course.getMaxElevation().doubleValue()); From 756bcd5625b79d3a6eee4ccc4632290286456ef9 Mon Sep 17 00:00:00 2001 From: ssggii Date: Fri, 15 Aug 2025 21:21:12 +0900 Subject: [PATCH 45/45] =?UTF-8?q?MERGE:=205=EC=B0=A8=20=EC=8A=A4=ED=94=84?= =?UTF-8?q?=EB=A6=B0=ED=8A=B8=20=EB=A7=88=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit