From 757b47c2c3d5525e0b6a16c34d967e9831c5c682 Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Wed, 6 May 2026 02:52:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=82=B0=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0,=20=EC=97=B4=EA=B1=B0?= =?UTF-8?q?=ED=98=95,=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/domain/mountain/entity/Amenity.java | 30 +++++++++++++ .../api/domain/mountain/entity/Course.java | 36 +++++++++++++++ .../api/domain/mountain/entity/Mountain.java | 44 +++++++++++++++++++ .../domain/mountain/entity/Restaurant.java | 31 +++++++++++++ .../mountain/entity/RestaurantSection.java | 32 ++++++++++++++ .../mountain/entity/Transportation.java | 36 +++++++++++++++ .../domain/mountain/enums/AmenityType.java | 5 +++ .../api/domain/mountain/enums/Difficulty.java | 5 +++ .../mountain/enums/TransportationType.java | 5 +++ .../repository/AmenityRepository.java | 11 +++++ .../mountain/repository/CourseRepository.java | 11 +++++ .../repository/MountainRepository.java | 14 ++++++ .../repository/RestaurantRepository.java | 7 +++ .../RestaurantSectionRepository.java | 14 ++++++ .../repository/TransportationRepository.java | 11 +++++ 15 files changed, 292 insertions(+) create mode 100644 src/main/java/com/semosan/api/domain/mountain/entity/Amenity.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/entity/Course.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/entity/Mountain.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/entity/Restaurant.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/entity/RestaurantSection.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/entity/Transportation.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/enums/AmenityType.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/enums/Difficulty.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/enums/TransportationType.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/repository/AmenityRepository.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/repository/MountainRepository.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/repository/RestaurantRepository.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/repository/RestaurantSectionRepository.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/repository/TransportationRepository.java diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Amenity.java b/src/main/java/com/semosan/api/domain/mountain/entity/Amenity.java new file mode 100644 index 00000000..c967bc45 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Amenity.java @@ -0,0 +1,30 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.AmenityType; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "amenities") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Amenity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private AmenityType type; + + @Column(name = "direction", nullable = false, length = 50) + private String direction; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java new file mode 100644 index 00000000..b3af49fa --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java @@ -0,0 +1,36 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.Difficulty; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "courses") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Course extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "difficulty", nullable = false, length = 20) + private Difficulty difficulty; + + @Column(name = "distance", nullable = false) + private Double distance; + + @Column(name = "duration", nullable = false) + private Integer duration; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Mountain.java b/src/main/java/com/semosan/api/domain/mountain/entity/Mountain.java new file mode 100644 index 00000000..98d13fa4 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Mountain.java @@ -0,0 +1,44 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.Difficulty; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "mountains") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mountain extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "address", nullable = false, length = 255) + private String address; + + @Column(name = "altitude", nullable = false) + private Double altitude; + + @Enumerated(EnumType.STRING) + @Column(name = "difficulty", nullable = false, length = 20) + private Difficulty difficulty; + + @Column(name = "duration") + private Integer duration; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Column(name = "latitude") + private Double latitude; + + @Column(name = "longitude") + private Double longitude; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Restaurant.java b/src/main/java/com/semosan/api/domain/mountain/entity/Restaurant.java new file mode 100644 index 00000000..a365ec7f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Restaurant.java @@ -0,0 +1,31 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "restaurants") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Restaurant extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "section_id", nullable = false) + private RestaurantSection section; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "category", length = 50) + private String category; + + @Column(name = "image_url", length = 500) + private String imageUrl; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/RestaurantSection.java b/src/main/java/com/semosan/api/domain/mountain/entity/RestaurantSection.java new file mode 100644 index 00000000..fb129bd0 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/RestaurantSection.java @@ -0,0 +1,32 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Table(name = "restaurant_sections") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RestaurantSection extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @OneToMany(mappedBy = "section", fetch = FetchType.LAZY) + @Builder.Default + private List restaurants = new ArrayList<>(); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Transportation.java b/src/main/java/com/semosan/api/domain/mountain/entity/Transportation.java new file mode 100644 index 00000000..df8faf25 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Transportation.java @@ -0,0 +1,36 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.TransportationType; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "transportations") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Transportation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private TransportationType type; + + @Column(name = "direction", nullable = false, length = 50) + private String direction; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/enums/AmenityType.java b/src/main/java/com/semosan/api/domain/mountain/enums/AmenityType.java new file mode 100644 index 00000000..314c1486 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/enums/AmenityType.java @@ -0,0 +1,5 @@ +package com.semosan.api.domain.mountain.enums; + +public enum AmenityType { + RESTROOM, INFORMATION, SHELTER, PARKING, STORE +} diff --git a/src/main/java/com/semosan/api/domain/mountain/enums/Difficulty.java b/src/main/java/com/semosan/api/domain/mountain/enums/Difficulty.java new file mode 100644 index 00000000..3deac48b --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/enums/Difficulty.java @@ -0,0 +1,5 @@ +package com.semosan.api.domain.mountain.enums; + +public enum Difficulty { + EASY, NORMAL, HARD +} diff --git a/src/main/java/com/semosan/api/domain/mountain/enums/TransportationType.java b/src/main/java/com/semosan/api/domain/mountain/enums/TransportationType.java new file mode 100644 index 00000000..6c0d97ea --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/enums/TransportationType.java @@ -0,0 +1,5 @@ +package com.semosan.api.domain.mountain.enums; + +public enum TransportationType { + SUBWAY, BUS, PARKING +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/AmenityRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/AmenityRepository.java new file mode 100644 index 00000000..784ec5a4 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/AmenityRepository.java @@ -0,0 +1,11 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Amenity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AmenityRepository extends JpaRepository { + + List findByMountainId(Long mountainId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java new file mode 100644 index 00000000..55c85e31 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java @@ -0,0 +1,11 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Course; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CourseRepository extends JpaRepository { + + List findByMountainId(Long mountainId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/MountainRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/MountainRepository.java new file mode 100644 index 00000000..760e59d1 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/MountainRepository.java @@ -0,0 +1,14 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Mountain; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MountainRepository extends JpaRepository { + + @Query("SELECT m FROM Mountain m WHERE m.name LIKE CONCAT('%', :keyword, '%') OR m.address LIKE CONCAT('%', :keyword, '%')") + List searchByKeyword(@Param("keyword") String keyword); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantRepository.java new file mode 100644 index 00000000..5afd63da --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantRepository.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Restaurant; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RestaurantRepository extends JpaRepository { +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantSectionRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantSectionRepository.java new file mode 100644 index 00000000..a1f318b2 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantSectionRepository.java @@ -0,0 +1,14 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.RestaurantSection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface RestaurantSectionRepository extends JpaRepository { + + @Query("SELECT DISTINCT s FROM RestaurantSection s LEFT JOIN FETCH s.restaurants WHERE s.mountain.id = :mountainId") + List findByMountainIdWithRestaurants(@Param("mountainId") Long mountainId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/TransportationRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/TransportationRepository.java new file mode 100644 index 00000000..ddb564e7 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/TransportationRepository.java @@ -0,0 +1,11 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Transportation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TransportationRepository extends JpaRepository { + + List findByMountainId(Long mountainId); +} From af3bdaaa8dc444c7eb1900bd1d7cf3ccc76541b7 Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Wed, 6 May 2026 02:52:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/domain/review/entity/Review.java | 44 +++++++++++++++++++ .../review/repository/ReviewRepository.java | 16 +++++++ .../domain/review/service/ReviewService.java | 23 ++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/main/java/com/semosan/api/domain/review/entity/Review.java create mode 100644 src/main/java/com/semosan/api/domain/review/repository/ReviewRepository.java create mode 100644 src/main/java/com/semosan/api/domain/review/service/ReviewService.java diff --git a/src/main/java/com/semosan/api/domain/review/entity/Review.java b/src/main/java/com/semosan/api/domain/review/entity/Review.java new file mode 100644 index 00000000..be5590f7 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/review/entity/Review.java @@ -0,0 +1,44 @@ +package com.semosan.api.domain.review.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.enums.Difficulty; +import com.semosan.api.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "reviews") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Review extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id") + private Course course; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "difficulty", nullable = false, length = 20) + private Difficulty difficulty; + + @Column(name = "image_url", length = 500) + private String imageUrl; +} diff --git a/src/main/java/com/semosan/api/domain/review/repository/ReviewRepository.java b/src/main/java/com/semosan/api/domain/review/repository/ReviewRepository.java new file mode 100644 index 00000000..2fea9860 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/review/repository/ReviewRepository.java @@ -0,0 +1,16 @@ +package com.semosan.api.domain.review.repository; + +import com.semosan.api.domain.review.entity.Review; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"user", "course"}) + @Query("SELECT r FROM Review r WHERE r.mountain.id = :mountainId ORDER BY r.createdAt DESC LIMIT :limit") + List findRecentByMountainId(@Param("mountainId") Long mountainId, @Param("limit") int limit); +} diff --git a/src/main/java/com/semosan/api/domain/review/service/ReviewService.java b/src/main/java/com/semosan/api/domain/review/service/ReviewService.java new file mode 100644 index 00000000..63557112 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/review/service/ReviewService.java @@ -0,0 +1,23 @@ +package com.semosan.api.domain.review.service; + +import com.semosan.api.domain.review.entity.Review; +import com.semosan.api.domain.review.repository.ReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private static final int DEFAULT_REVIEW_LIMIT = 20; + + private final ReviewRepository reviewRepository; + + public List getReviewsByMountainId(Long mountainId) { + return reviewRepository.findRecentByMountainId(mountainId, DEFAULT_REVIEW_LIMIT); + } +} From dc6ed95cf5ad771be475ef7ed3062cb709fbdb64 Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Wed, 6 May 2026 02:52:40 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=82=B0=20=EB=AA=A9=EB=A1=9D/?= =?UTF-8?q?=EA=B2=80=EC=83=89/=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/status/ErrorStatus.java | 10 ++ .../api/common/status/SuccessStatus.java | 14 +- .../controller/MountainController.java | 46 ++++++ .../docs/MountainControllerDocs.java | 67 ++++++++ .../dto/response/MountainDetailResponse.java | 156 ++++++++++++++++++ .../dto/response/MountainListResponse.java | 31 ++++ .../mountain/service/MountainService.java | 89 ++++++++++ 7 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/semosan/api/domain/mountain/controller/MountainController.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/controller/docs/MountainControllerDocs.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/dto/response/MountainDetailResponse.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/dto/response/MountainListResponse.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/service/MountainService.java diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 742d3e2e..457a13c4 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -57,6 +57,16 @@ public enum ErrorStatus implements BaseStatus { APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.UNAUTHORIZED, "APPLE_401_1", "유효한 애플 공개키를 찾을 수 없습니다."), APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "APPLE_401_2", "애플 identity token이 유효하지 않습니다."), + /** + * Mountain + */ + MOUNTAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_1", "산을 찾을 수 없습니다."), + + /** + * Image + */ + IMAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "IMG_502_1", "이미지 업로드 URL 생성에 실패했습니다."), + /** * Withdraw */ diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 0faf01ae..e6fb87c5 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -20,7 +20,19 @@ public enum SuccessStatus implements BaseStatus { /** * User */ - WITHDRAW_SUCCESS(HttpStatus.OK, "USER_200_1", "회원 탈퇴가 완료되었습니다."); + WITHDRAW_SUCCESS(HttpStatus.OK, "USER_200_1", "회원 탈퇴가 완료되었습니다."), + + /** + * Mountain + */ + MOUNTAIN_LIST_SUCCESS(HttpStatus.OK, "MTN_200_1", "산 목록 조회에 성공했습니다."), + MOUNTAIN_SEARCH_SUCCESS(HttpStatus.OK, "MTN_200_2", "산 검색에 성공했습니다."), + MOUNTAIN_DETAIL_SUCCESS(HttpStatus.OK, "MTN_200_3", "산 상세 정보 조회에 성공했습니다."), + + /** + * Image + */ + PRESIGNED_URL_SUCCESS(HttpStatus.OK, "IMG_200_1", "Presigned URL 발급에 성공했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/MountainController.java b/src/main/java/com/semosan/api/domain/mountain/controller/MountainController.java new file mode 100644 index 00000000..d895c1b7 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/controller/MountainController.java @@ -0,0 +1,46 @@ +package com.semosan.api.domain.mountain.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.mountain.controller.docs.MountainControllerDocs; +import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse; +import com.semosan.api.domain.mountain.dto.response.MountainListResponse; +import com.semosan.api.domain.mountain.service.MountainService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/mountains") +@RequiredArgsConstructor +public class MountainController implements MountainControllerDocs { + + private final MountainService mountainService; + + @GetMapping + @Override + public ResponseEntity>> getMountains() { + List response = mountainService.getMountains(); + return ApiResponse.success(SuccessStatus.MOUNTAIN_LIST_SUCCESS, response); + } + + @GetMapping("/search") + @Override + public ResponseEntity>> searchMountains( + @RequestParam String keyword + ) { + List response = mountainService.searchMountains(keyword); + return ApiResponse.success(SuccessStatus.MOUNTAIN_SEARCH_SUCCESS, response); + } + + @GetMapping("/{mountainId}") + @Override + public ResponseEntity> getMountainDetail( + @PathVariable Long mountainId + ) { + MountainDetailResponse response = mountainService.getMountainDetail(mountainId); + return ApiResponse.success(SuccessStatus.MOUNTAIN_DETAIL_SUCCESS, response); + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/docs/MountainControllerDocs.java b/src/main/java/com/semosan/api/domain/mountain/controller/docs/MountainControllerDocs.java new file mode 100644 index 00000000..8cdb18b1 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/controller/docs/MountainControllerDocs.java @@ -0,0 +1,67 @@ +package com.semosan.api.domain.mountain.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse; +import com.semosan.api.domain.mountain.dto.response.MountainListResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "Mountain", description = "산 관련 API") +public interface MountainControllerDocs { + + @Operation( + summary = "산 목록 조회", + description = "등록된 모든 산의 목록을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "산 목록 조회 성공" + ) + }) + ResponseEntity>> getMountains(); + + @Operation( + summary = "산 검색", + description = "산 이름 또는 주소로 검색합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "산 검색 성공" + ) + }) + ResponseEntity>> searchMountains( + @Parameter(description = "검색 키워드 (산 이름 또는 주소)", required = true) + @RequestParam String keyword + ); + + @Operation( + summary = "산 상세 정보 조회", + description = "산의 상세 정보를 조회합니다. 코스, 교통, 편의시설, 맛집, 리뷰 정보를 포함합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "산 상세 정보 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "산을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> getMountainDetail( + @Parameter(description = "산 ID", required = true) + @PathVariable Long mountainId + ); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainDetailResponse.java b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainDetailResponse.java new file mode 100644 index 00000000..e952a381 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainDetailResponse.java @@ -0,0 +1,156 @@ +package com.semosan.api.domain.mountain.dto.response; + +import com.semosan.api.domain.mountain.entity.*; +import com.semosan.api.domain.mountain.enums.AmenityType; +import com.semosan.api.domain.mountain.enums.Difficulty; +import com.semosan.api.domain.mountain.enums.TransportationType; +import com.semosan.api.domain.review.entity.Review; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record MountainDetailResponse( + MountainInfo mountain, + List courses, + TransportationGroup transportations, + Map> amenities, + List restaurantSections, + List reviews +) { + + public record MountainInfo( + Long mountainId, + String name, + String address, + Double altitude, + Difficulty difficulty, + Integer duration, + String imageUrl + ) { + public static MountainInfo from(Mountain mountain) { + return new MountainInfo( + mountain.getId(), + mountain.getName(), + mountain.getAddress(), + mountain.getAltitude(), + mountain.getDifficulty(), + mountain.getDuration(), + mountain.getImageUrl() + ); + } + } + + public record CourseInfo( + Long courseId, + String name, + Difficulty difficulty, + Double distance, + Integer duration + ) { + public static CourseInfo from(Course course) { + return new CourseInfo( + course.getId(), + course.getName(), + course.getDifficulty(), + course.getDistance(), + course.getDuration() + ); + } + } + + public record TransportationGroup( + Map> publicTransport, + Map> parking + ) { + public static TransportationGroup from(List transportations) { + Map> publicTransport = transportations.stream() + .filter(t -> t.getType() == TransportationType.SUBWAY || t.getType() == TransportationType.BUS) + .collect(Collectors.groupingBy( + Transportation::getDirection, + Collectors.mapping(TransportationItem::from, Collectors.toList()) + )); + Map> parking = transportations.stream() + .filter(t -> t.getType() == TransportationType.PARKING) + .collect(Collectors.groupingBy( + Transportation::getDirection, + Collectors.mapping(TransportationItem::from, Collectors.toList()) + )); + return new TransportationGroup(publicTransport, parking); + } + } + + public record TransportationItem( + Long transportationId, + TransportationType type, + String name, + String description + ) { + public static TransportationItem from(Transportation transportation) { + return new TransportationItem( + transportation.getId(), + transportation.getType(), + transportation.getName(), + transportation.getDescription() + ); + } + } + + public static Map> groupAmenities(List amenities) { + return amenities.stream() + .collect(Collectors.groupingBy( + Amenity::getDirection, + Collectors.mapping(Amenity::getType, Collectors.toList()) + )); + } + + public record RestaurantSectionInfo( + String title, + List restaurants + ) { + public static RestaurantSectionInfo from(RestaurantSection section) { + return new RestaurantSectionInfo( + section.getTitle(), + section.getRestaurants().stream() + .map(RestaurantInfo::from) + .toList() + ); + } + } + + public record RestaurantInfo( + Long restaurantId, + String name, + String category, + String imageUrl + ) { + public static RestaurantInfo from(Restaurant restaurant) { + return new RestaurantInfo( + restaurant.getId(), + restaurant.getName(), + restaurant.getCategory(), + restaurant.getImageUrl() + ); + } + } + + public record ReviewInfo( + Long reviewId, + String imageUrl, + String authorName, + String content, + Difficulty difficulty, + String courseName + ) { + public static ReviewInfo from(Review review) { + return new ReviewInfo( + review.getId(), + review.getImageUrl(), + review.getUser().getName(), + review.getContent(), + review.getDifficulty(), + review.getCourse() != null ? review.getCourse().getName() : null + ); + } + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainListResponse.java b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainListResponse.java new file mode 100644 index 00000000..b37b3f6f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainListResponse.java @@ -0,0 +1,31 @@ +package com.semosan.api.domain.mountain.dto.response; + +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.enums.Difficulty; + +public record MountainListResponse( + Long mountainId, + String name, + String address, + Double altitude, + Difficulty difficulty, + Integer duration, + String imageUrl, + Double latitude, + Double longitude +) { + + public static MountainListResponse from(Mountain mountain) { + return new MountainListResponse( + mountain.getId(), + mountain.getName(), + mountain.getAddress(), + mountain.getAltitude(), + mountain.getDifficulty(), + mountain.getDuration(), + mountain.getImageUrl(), + mountain.getLatitude(), + mountain.getLongitude() + ); + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/service/MountainService.java b/src/main/java/com/semosan/api/domain/mountain/service/MountainService.java new file mode 100644 index 00000000..e06dfecc --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/service/MountainService.java @@ -0,0 +1,89 @@ +package com.semosan.api.domain.mountain.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse; +import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse.*; +import com.semosan.api.domain.mountain.dto.response.MountainListResponse; +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.enums.AmenityType; +import com.semosan.api.domain.mountain.repository.*; +import com.semosan.api.domain.review.service.ReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MountainService { + + private final MountainRepository mountainRepository; + private final CourseRepository courseRepository; + private final TransportationRepository transportationRepository; + private final AmenityRepository amenityRepository; + private final RestaurantSectionRepository restaurantSectionRepository; + private final ReviewService reviewService; + + public List getMountains() { + return mountainRepository.findAll().stream() + .map(MountainListResponse::from) + .toList(); + } + + public List searchMountains(String keyword) { + if (keyword == null || keyword.isBlank()) { + throw new GeneralException(ErrorStatus.BAD_REQUEST); + } + return mountainRepository.searchByKeyword(keyword.trim()).stream() + .map(MountainListResponse::from) + .toList(); + } + + public MountainDetailResponse getMountainDetail(Long mountainId) { + Mountain mountain = findMountainById(mountainId); + + return new MountainDetailResponse( + MountainInfo.from(mountain), + findCourses(mountainId), + findTransportationGroup(mountainId), + findAmenitiesByDirection(mountainId), + findRestaurantSections(mountainId), + findReviews(mountainId) + ); + } + + private Mountain findMountainById(Long mountainId) { + return mountainRepository.findById(mountainId) + .orElseThrow(() -> new GeneralException(ErrorStatus.MOUNTAIN_NOT_FOUND)); + } + + private List findCourses(Long mountainId) { + return courseRepository.findByMountainId(mountainId).stream() + .map(CourseInfo::from) + .toList(); + } + + private TransportationGroup findTransportationGroup(Long mountainId) { + return TransportationGroup.from(transportationRepository.findByMountainId(mountainId)); + } + + private Map> findAmenitiesByDirection(Long mountainId) { + return MountainDetailResponse.groupAmenities(amenityRepository.findByMountainId(mountainId)); + } + + private List findRestaurantSections(Long mountainId) { + return restaurantSectionRepository.findByMountainIdWithRestaurants(mountainId).stream() + .map(RestaurantSectionInfo::from) + .toList(); + } + + private List findReviews(Long mountainId) { + return reviewService.getReviewsByMountainId(mountainId).stream() + .map(ReviewInfo::from) + .toList(); + } +} From 94b21eaee9e16a2086f0e068410316c4c34110fc Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Wed, 6 May 2026 02:53:08 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20application.yaml=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d1ab33e9..e5f751d6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -4,3 +4,31 @@ spring: profiles: default: local + + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +jwt: + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + +kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + client-secret: ${KAKAO_CLIENT_SECRET} + admin-key: ${KAKAO_ADMIN_KEY} + +test: + secret-key: ${TEST_SECRET_KEY}